From 5f4f5ca9b0875149ca5be443940560ddf8955ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Mon, 3 Aug 2026 00:16:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20localize=20Settings=20=E2=86=92?= =?UTF-8?q?=20Integrations=20=E2=86=92=20Sentry=20(#2182)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 5 (1M context) --- .../sentry/sentry-instance-card.tsx | 10 +- .../sentry/sentry-instance-form.tsx | 99 ++++++---- .../sentry/sentry-issue-watch-dialog.tsx | 104 ++++++----- .../sentry-issue-watch-filter-fields.tsx | 67 ++++--- .../sentry/sentry-issue-watch-form.ts | 38 +++- .../sentry/sentry-issue-watch-multiselect.tsx | 38 ++-- .../sentry/sentry-issue-watch-placeholders.ts | 173 ++++++++++-------- .../sentry/sentry-issue-watch-table.tsx | 68 ++++--- .../sentry-issue-watch-throttle-field.tsx | 13 +- .../sentry/sentry-issue-watchers-section.tsx | 54 +++--- .../web/components/sentry/sentry-settings.tsx | 83 +++++---- .../e2e/tests/i18n/pseudo-coverage.spec.ts | 14 +- apps/web/eslint.i18n.options.mjs | 36 ++++ apps/web/src/locales/en/sentry.json | 154 ++++++++++++++++ apps/web/src/locales/pseudo/sentry.json | 154 ++++++++++++++++ apps/web/src/locales/pseudo/settings.json | 22 +-- 16 files changed, 823 insertions(+), 304 deletions(-) create mode 100644 apps/web/src/locales/en/sentry.json create mode 100644 apps/web/src/locales/pseudo/sentry.json diff --git a/apps/web/components/sentry/sentry-instance-card.tsx b/apps/web/components/sentry/sentry-instance-card.tsx index f8bffccd31..5ce05accd6 100644 --- a/apps/web/components/sentry/sentry-instance-card.tsx +++ b/apps/web/components/sentry/sentry-instance-card.tsx @@ -7,6 +7,7 @@ import { type IntegrationAuthHealth, } from "@/components/integrations/auth-status-banner"; import type { SentryConfig } from "@/lib/types/sentry"; +import { useTranslation } from "react-i18next"; // configToHealth maps an instance's backend-recorded probe fields to the shared // auth-status banner shape. Returns null (banner hidden) until a secret exists. @@ -29,6 +30,7 @@ type SentryInstanceCardProps = { // SentryInstanceCard renders one saved instance: its name, URL, per-instance // auth-health banner, and edit/delete actions. export function SentryInstanceCard({ instance, onEdit, onDelete }: SentryInstanceCardProps) { + const { t } = useTranslation(); return (
@@ -44,24 +46,24 @@ export function SentryInstanceCard({ instance, onEdit, onDelete }: SentryInstanc size="sm" variant="outline" onClick={onEdit} - aria-label={`Edit ${instance.name} Sentry instance`} + aria-label={t("sentry:editInstanceAria", { name: instance.name })} className="cursor-pointer gap-1" data-testid="sentry-instance-edit-button" > - Edit + {t("sentry:edit")}
diff --git a/apps/web/components/sentry/sentry-instance-form.tsx b/apps/web/components/sentry/sentry-instance-form.tsx index faee094a24..5c643c2362 100644 --- a/apps/web/components/sentry/sentry-instance-form.tsx +++ b/apps/web/components/sentry/sentry-instance-form.tsx @@ -24,10 +24,27 @@ import { type SentryConfig, type TestSentryConnectionResult, } from "@/lib/types/sentry"; +import { useTranslation } from "react-i18next"; const FIELD = "space-y-1.5"; const HELP = "text-xs text-muted-foreground"; +// None of the four below is copy, so none reaches the catalog — the +// pseudo-locale would transliterate them into values the user cannot act on. +// SECRET_MASK is a glyph run standing in for a stored token; AUTH_TOKEN_HINT is +// the literal prefix every Sentry auth token carries; SELF_HOSTED_EXAMPLE_URL is +// a hostname; and each TOKEN_SCOPES `scope` is an API scope identifier the user +// must tick verbatim in Sentry. Only the scope descriptions are translatable, so +// they travel as `descriptionKey` and resolve at render. +const SECRET_MASK = "••••••••"; +const AUTH_TOKEN_HINT = "sntrys_..."; +const SELF_HOSTED_EXAMPLE_URL = "https://sentry.your-company.com"; +const TOKEN_SCOPES: { scope: string; descriptionKey: string }[] = [ + { scope: "org:read", descriptionKey: "sentry:scopeOrgRead" }, + { scope: "project:read", descriptionKey: "sentry:scopeProjectRead" }, + { scope: "event:read", descriptionKey: "sentry:scopeEventRead" }, +]; + type FormState = { name: string; url: string; secret: string }; function instanceToForm(instance: SentryConfig | null): FormState { @@ -43,27 +60,29 @@ type FieldProps = { }; function NameField({ form, baseline, idPrefix, loading, update }: FieldProps) { + const { t } = useTranslation(); return (
- + update("name", e.target.value)} disabled={loading} /> -

A label for this instance. Must be unique within the workspace.

+

{t("sentry:nameHelp")}

); } function UrlField({ form, baseline, idPrefix, loading, update }: FieldProps) { + const { t } = useTranslation(); return (
- + update("url", e.target.value)} disabled={loading} /> + {/* Both URLs are interpolated rather than written into the message: the + pseudo-locale must not transliterate a hostname the user has to type. */}

- Base URL of your Sentry instance. Leave as {SENTRY_DEFAULT_URL} for Sentry SaaS, or point it - at a self-hosted install (e.g. https://sentry.your-company.com). + {t("sentry:instanceUrlHelp", { + defaultUrl: SENTRY_DEFAULT_URL, + exampleUrl: SELF_HOSTED_EXAMPLE_URL, + })}

); @@ -90,14 +113,15 @@ function SecretField({ update, hasSavedSecret, }: FieldProps & { hasSavedSecret: boolean }) { + const { t } = useTranslation(); return (
@@ -105,26 +129,18 @@ function SecretField({ -

Grant Read access to these scopes:

+

{t("sentry:grantReadAccess")}

    -
  • - org:read{" "} - Organization — resolve the org and list issues -
  • -
  • - project:read{" "} - Project — list projects and scope searches -
  • -
  • - event:read{" "} - - Issue & Event — browse issues and run watchers - -
  • + {TOKEN_SCOPES.map(({ scope, descriptionKey }) => ( +
  • + {scope}{" "} + {t(descriptionKey)} +
  • + ))}
@@ -133,7 +149,7 @@ function SecretField({ id={`${idPrefix}-secret`} data-testid={`${idPrefix}-secret-input`} type="password" - placeholder={hasSavedSecret ? "••••••••" : "sntrys_..."} + placeholder={hasSavedSecret ? SECRET_MASK : AUTH_TOKEN_HINT} value={form.secret} data-settings-dirty={form.secret !== baseline.secret} onChange={(e) => update("secret", e.target.value)} @@ -144,13 +160,18 @@ function SecretField({ } function TestResultAlert({ result }: { result: TestSentryConnectionResult | null }) { + const { t } = useTranslation(); if (!result) return null; return ( + {/* The identity and the error text are both server data, interpolated so + neither is ever routed through the catalog. */} {result.ok - ? `Connected as ${result.displayName || result.email || result.userId}` - : `Failed: ${result.error}`} + ? t("sentry:connectedAs", { + name: result.displayName || result.email || result.userId, + }) + : t("sentry:testFailed", { error: result.error })} ); @@ -163,6 +184,7 @@ type UseInstanceFormArgs = { }; function useInstanceForm({ workspaceId, instance, form }: UseInstanceFormArgs) { + const { t } = useTranslation(); const { toast } = useToast(); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); @@ -207,19 +229,21 @@ function useInstanceForm({ workspaceId, instance, form }: UseInstanceFormArgs) { url: form.url, secret: form.secret, }); - toast({ description: "Sentry instance saved", variant: "success" }); + toast({ description: t("sentry:instanceSaved"), variant: "success" }); return saved; } catch (err) { + // The name is the user's own; the fallback carries the raw server error. + // Both are interpolated so neither is translated. const message = sentryErrorCode(err) === SENTRY_ERROR_CODES.nameTaken - ? `An instance named "${form.name.trim()}" already exists in this workspace.` - : `Save failed: ${String(err)}`; + ? t("sentry:instanceNameTaken", { name: form.name.trim() }) + : t("sentry:saveFailed", { error: String(err) }); toast({ description: message, variant: "error" }); throw err; } finally { setSaving(false); } - }, [workspaceId, instance, form, toast]); + }, [workspaceId, instance, form, toast, t]); return { saving, testing, testResult, candidateTest, handleTest, handleSave }; } @@ -253,6 +277,7 @@ function useCoordinatedInstanceSave({ onSaved, canSave, }: CoordinatedSaveOptions) { + const { t } = useTranslation(); const [baseline, setBaseline] = useState(() => instanceToForm(instance)); const revision = JSON.stringify(form); const latestRevision = useRef(revision); @@ -264,7 +289,7 @@ function useCoordinatedInstanceSave({ revision, isDirty, canSave, - invalidReason: canSave ? undefined : "Enter an instance name and auth token before saving.", + invalidReason: canSave ? undefined : t("sentry:enterNameAndTokenBeforeSaving"), save: async (submittedRevision) => { const submitted = form; const saved = await handleSave(); @@ -294,6 +319,7 @@ function FormActions({ onTest, onCancel, }: FormActionsProps) { + const { t } = useTranslation(); return (
); @@ -328,6 +354,7 @@ export function SentryInstanceForm({ onCancel, onDirtyChange, }: SentryInstanceFormProps) { + const { t } = useTranslation(); const [form, setForm] = useState(() => instanceToForm(instance)); const update = useCallback( (key: K, value: FormState[K]) => @@ -365,7 +392,7 @@ export function SentryInstanceForm({ > {instance === null && (

- New Instance + {t("sentry:newInstance")}

)} @@ -87,9 +95,9 @@ function PlaceholdersHelp() { -

Available placeholders:

+

{t("sentry:availablePlaceholders")}

    - {SENTRY_ISSUE_WATCH_PLACEHOLDERS.map((p) => ( + {placeholders.map((p) => (
  • {`{{${p.key}}}`}{" "} {p.description} @@ -103,14 +111,23 @@ function PlaceholdersHelp() { } function PromptField({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const { t } = useTranslation(); + // Memoized because ScriptEditor keys its completion-provider registration on + // `placeholders` identity. This used to be a module-scope const, so it was + // stable for free; now that it is built from `t`, a fresh array on every + // render would re-register the provider on every keystroke. The tooltip below + // shares this array rather than building its own. + const placeholders = useMemo(() => sentryIssueWatchPlaceholders(t), [t]); return (
    - - + +

    - The prompt sent to the agent for each new issue. Type {"{{"} to insert placeholders. + {/* The `{{` token is passed as a value so it never reaches the catalog, + where i18next would interpolate it away. */} + {t("sentry:promptFieldHelp", { token: "{{" })}

    @@ -135,14 +152,15 @@ function WorkspacePicker({ onChange: (v: string) => void; disabled?: boolean; }) { + const { t } = useTranslation(); const workspaces = useAppStore((s) => s.workspaces.items); return ( ({ id: w.id, label: w.name }))} disabled={disabled} /> @@ -193,14 +211,15 @@ function InstancePicker({ onChange: (v: string) => void; disabled?: boolean; }) { + const { t } = useTranslation(); const noInstances = instances.length === 0; return ( ({ id: i.id, label: i.name }))} disabled={disabled || noInstances} /> @@ -208,22 +227,23 @@ function InstancePicker({ } function AutomationFields({ form, setForm }: { form: FormState; setForm: FormSetter }) { + const { t } = useTranslation(); const { workflows, agentProfiles, allExecutorProfiles } = useFormData(form.workspaceId); const { steps, loading: stepsLoading } = useWorkflowSteps(form.workflowId); return ( <>
    setForm((p) => ({ ...p, workflowId: v, workflowStepId: "" }))} - placeholder="Select workflow" + placeholder={t("sentry:selectWorkflow")} items={workflows.map((w) => ({ id: w.id, label: w.name }))} /> setForm((p) => ({ ...p, workflowStepId: v }))} placeholder={stepPlaceholder(form.workflowId, stepsLoading, steps.length)} @@ -242,19 +262,19 @@ function AutomationFields({ form, setForm }: { form: FormState; setForm: FormSet />
    setForm((p) => ({ ...p, agentProfileId: v }))} - placeholder="(use step default)" + placeholder={t("sentry:useStepDefault")} items={agentProfiles.map((p) => ({ id: p.id, label: p.label }))} /> setForm((p) => ({ ...p, executorProfileId: v }))} - placeholder="(use step default)" + placeholder={t("sentry:useStepDefault")} items={allExecutorProfiles.map((p) => ({ id: p.id, label: p.name }))} />
    @@ -263,13 +283,12 @@ function AutomationFields({ form, setForm }: { form: FormState; setForm: FormSet } function SettingsFields({ form, setForm }: { form: FormState; setForm: FormSetter }) { + const { t } = useTranslation(); return ( <>
    - -

    - How often to re-run the search. Minimum 60s, maximum 3600s. -

    + +

    {t("sentry:pollIntervalHelp")}

    - -

    Pause or resume polling.

    + +

    {t("sentry:enabledHelp")}

    s.workspaces.activeId); const [saving, setSaving] = useState(false); const [form, setForm] = useState(() => makeEmptyForm(workspaceId ?? "")); @@ -388,12 +410,10 @@ export function SentryIssueWatchDialog({ - {watch ? "Edit Sentry Watcher" : "Create Sentry Watcher"} - - Poll Sentry with a structured filter and auto-create a Kandev task for each - newly-matching issue. Optionally bind a repository so each task runs against that - codebase, or leave it unset to run with no repository. - + + {watch ? t("sentry:editSentryWatcher") : t("sentry:createSentryWatcher")} + + {t("sentry:watchDialogDescription")}
    diff --git a/apps/web/components/sentry/sentry-issue-watch-filter-fields.tsx b/apps/web/components/sentry/sentry-issue-watch-filter-fields.tsx index 29a6a025b4..008dc3ef60 100644 --- a/apps/web/components/sentry/sentry-issue-watch-filter-fields.tsx +++ b/apps/web/components/sentry/sentry-issue-watch-filter-fields.tsx @@ -17,9 +17,20 @@ import { projectMultiSelectItems, } from "./sentry-issue-watch-form"; import type { SentryLevel, SentryProject, SentryStatus } from "@/lib/types/sentry"; +import { useTranslation } from "react-i18next"; +import { resolveOptionLabel } from "@/lib/i18n/option-label"; export type FormSetter = React.Dispatch>; +// Both placeholders are Sentry syntax, not copy, so neither reaches the catalog: +// `production` is a real environment name the user is expected to type back, and +// the query example is Sentry search syntax — `is:` and `transaction:` are the +// service's own filter tokens and `unresolved` is a `SentryStatus` union member. +// Translating either would show the user a query Sentry cannot parse. Same call +// as the Jira watcher's example JQL and the Linear watcher's filter examples. +const ENVIRONMENT_PLACEHOLDER = "production"; +const QUERY_PLACEHOLDER = "is:unresolved transaction:/api/checkout"; + // useSentryProjects loads the workspace/instance's projects and narrows them // to the currently-selected org — Sentry's auth-token endpoint already // filters to the user's accessible orgs, so this is a client-side scope, not @@ -118,6 +129,7 @@ function OrgProjectRow({ projects: SentryProject[]; orgs: string[]; }) { + const { t } = useTranslation(); const onOrgChange = (v: string) => // The selected projects may belong to a different org — clear them so the // project picker re-populates within the new org. @@ -127,20 +139,26 @@ function OrgProjectRow({ return (
    setForm((p) => ({ ...p, projectSlugs: v }))} - placeholder={projectItems.length === 0 ? "No projects available" : "Select projects"} + placeholder={ + projectItems.length === 0 ? t("sentry:noProjectsAvailable") : t("sentry:selectProjects") + } items={projectItems} disabled={projectItems.length === 0} /> @@ -157,6 +175,7 @@ export function FilterFields({ setForm: FormSetter; orgs: string[]; }) { + const { t } = useTranslation(); const projects = useSentryProjects(form.workspaceId, form.sentryInstanceId, form.orgSlug); const toggleLevel = useCallback( (level: SentryLevel) => @@ -183,44 +202,44 @@ export function FilterFields({
    - -

    Optional — restrict to one environment.

    + +

    {t("sentry:environmentHelp")}

    setForm((p) => ({ ...p, environment: e.target.value }))} - placeholder="production" + placeholder={ENVIRONMENT_PLACEHOLDER} />
    setForm((p) => ({ ...p, statsPeriod: v }))} - placeholder="(any)" - items={STATS_PERIOD_OPTIONS.map((o) => ({ id: o.value, label: o.label }))} + placeholder={t("sentry:any")} + // `id` stays the Sentry statsPeriod token; only the menu text is copy. + items={STATS_PERIOD_OPTIONS.map((o) => ({ + id: o.value, + label: resolveOptionLabel(t, o), + }))} />
    - -

    - Click to toggle. Matches issues at ANY of the selected levels. -

    + +

    {t("sentry:levelsHelp")}

    - -

    - Click to toggle. Matches issues at ANY of the selected statuses. -

    + +

    {t("sentry:statusesHelp")}

    - -

    Free-text Sentry search query (optional).

    + +

    {t("sentry:queryHelp")}

    setForm((p) => ({ ...p, query: e.target.value }))} - placeholder="is:unresolved transaction:/api/checkout" + placeholder={QUERY_PLACEHOLDER} />
    diff --git a/apps/web/components/sentry/sentry-issue-watch-form.ts b/apps/web/components/sentry/sentry-issue-watch-form.ts index 2eb60e1340..d2e95467b9 100644 --- a/apps/web/components/sentry/sentry-issue-watch-form.ts +++ b/apps/web/components/sentry/sentry-issue-watch-form.ts @@ -7,15 +7,37 @@ import type { } from "@/lib/types/sentry"; import { DEFAULT_SENTRY_ISSUE_WATCH_PROMPT } from "./sentry-issue-watch-placeholders"; -export const LEVEL_OPTIONS: SentryLevel[] = ["fatal", "error", "warning", "info", "debug"]; -export const STATUS_OPTIONS: SentryStatus[] = ["unresolved", "resolved", "ignored"]; +// Every `value` below is wire data, never copy: the levels and statuses are the +// `SentryLevel` / `SentryStatus` string-literal unions — compared with +// `includes`, persisted on the watch filter, and sent to Sentry verbatim — and +// the stats periods are Sentry's own `statsPeriod` tokens. Only the chip and +// menu text is translatable, so it travels as `labelKey` and resolves at render +// through `resolveOptionLabel`; a module-scope `t()` here would freeze the copy +// at the boot locale (see docs/i18n.md). +// +// The level/status labels are lowercase in the catalog because the chips render +// them under a CSS `uppercase`, so the source string a translator sees is the +// one the badge actually carries. +export const LEVEL_OPTIONS: { value: SentryLevel; labelKey: string }[] = [ + { value: "fatal", labelKey: "sentry:levelFatal" }, + { value: "error", labelKey: "sentry:levelError" }, + { value: "warning", labelKey: "sentry:levelWarning" }, + { value: "info", labelKey: "sentry:levelInfo" }, + { value: "debug", labelKey: "sentry:levelDebug" }, +]; -export const STATS_PERIOD_OPTIONS: { value: string; label: string }[] = [ - { value: "1h", label: "Last hour" }, - { value: "24h", label: "Last 24 hours" }, - { value: "7d", label: "Last 7 days" }, - { value: "14d", label: "Last 14 days" }, - { value: "30d", label: "Last 30 days" }, +export const STATUS_OPTIONS: { value: SentryStatus; labelKey: string }[] = [ + { value: "unresolved", labelKey: "sentry:statusUnresolved" }, + { value: "resolved", labelKey: "sentry:statusResolved" }, + { value: "ignored", labelKey: "sentry:statusIgnored" }, +]; + +export const STATS_PERIOD_OPTIONS: { value: string; labelKey: string }[] = [ + { value: "1h", labelKey: "sentry:statsPeriodLastHour" }, + { value: "24h", labelKey: "sentry:statsPeriodLast24Hours" }, + { value: "7d", labelKey: "sentry:statsPeriodLast7Days" }, + { value: "14d", labelKey: "sentry:statsPeriodLast14Days" }, + { value: "30d", labelKey: "sentry:statsPeriodLast30Days" }, ]; export interface FormState { diff --git a/apps/web/components/sentry/sentry-issue-watch-multiselect.tsx b/apps/web/components/sentry/sentry-issue-watch-multiselect.tsx index 7a28b2bf41..d3eadd5e41 100644 --- a/apps/web/components/sentry/sentry-issue-watch-multiselect.tsx +++ b/apps/web/components/sentry/sentry-issue-watch-multiselect.tsx @@ -9,6 +9,8 @@ import { cn } from "@/lib/utils"; import { levelBadgeClass, statusBadgeClass } from "./sentry-issue-common"; import { LEVEL_OPTIONS, STATUS_OPTIONS } from "./sentry-issue-watch-form"; import type { SentryLevel, SentryStatus } from "@/lib/types/sentry"; +import { useTranslation } from "react-i18next"; +import { resolveOptionLabel } from "@/lib/i18n/option-label"; // LevelMultiSelect / StatusMultiSelect are toggle-badge pickers: click a badge // to add/remove it from the selection. A match means ANY selected value. Both @@ -16,31 +18,38 @@ import type { SentryLevel, SentryStatus } from "@/lib/types/sentry"; // BadgeMultiSelect renders a toggle-badge picker for any string-union option // set. `colorClass` resolves the active-state styling for a given option. +// +// `value` is the union member — the key, the selection identity, and what goes +// on the wire. Only `labelKey` is copy, resolved here at render. function BadgeMultiSelect({ options, selected, onToggle, colorClass, }: { - options: readonly T[]; + options: readonly { value: T; labelKey: string }[]; selected: T[]; onToggle: (value: T) => void; colorClass: (value: T) => string; }) { + const { t } = useTranslation(); return (
    - {options.map((value) => { - const active = selected.includes(value); + {options.map((option) => { + const active = selected.includes(option.value); return ( ); @@ -94,7 +103,7 @@ export function ProjectMultiSelect({ options, selected, onChange, - placeholder = "Select projects", + placeholder, disabled = false, }: { options: ProjectMultiSelectOption[]; @@ -103,6 +112,7 @@ export function ProjectMultiSelect({ placeholder?: string; disabled?: boolean; }) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const labelById = new Map(options.map((o) => [o.id, o.label])); @@ -124,16 +134,16 @@ export function ProjectMultiSelect({ - + - No projects available. + {t("sentry:noProjectsAvailableSentence")} {options.map((opt) => { const checked = selected.includes(opt.id); return ( @@ -161,11 +171,15 @@ function ProjectMultiSelectSummary({ labelById: Map; placeholder: string; }) { + const { t } = useTranslation(); if (selected.length === 0) { return {placeholder}; } + // One selection shows the project's own name — runtime data, never translated. if (selected.length === 1) { return {labelById.get(selected[0]) ?? selected[0]}; } - return {selected.length} projects selected; + return ( + {t("sentry:projectsSelected", { count: selected.length })} + ); } diff --git a/apps/web/components/sentry/sentry-issue-watch-placeholders.ts b/apps/web/components/sentry/sentry-issue-watch-placeholders.ts index 20c22a42e2..e628344909 100644 --- a/apps/web/components/sentry/sentry-issue-watch-placeholders.ts +++ b/apps/web/components/sentry/sentry-issue-watch-placeholders.ts @@ -1,83 +1,108 @@ +import type { TFunction } from "i18next"; import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions"; -export const SENTRY_ISSUE_WATCH_PLACEHOLDERS: ScriptPlaceholder[] = [ - { - key: "issue.short_id", - description: "Sentry short ID", - example: "PROJ-123", - executor_types: [], - }, - { - key: "issue.title", - description: "Issue title", - example: "TypeError: cannot read property of undefined", - executor_types: [], - }, - { - key: "issue.url", - description: "Sentry issue permalink", - example: "https://sentry.io/organizations/acme/issues/123/", - executor_types: [], - }, - { - key: "issue.project", - description: "Sentry project slug", - example: "frontend", - executor_types: [], - }, - { - key: "issue.level", - description: "Issue level", - example: "error", - executor_types: [], - }, - { - key: "issue.status", - description: "Issue status", - example: "unresolved", - executor_types: [], - }, - { - key: "issue.culprit", - description: "Culprit (function or file)", - example: "app/routes/checkout.tsx in handleSubmit", - executor_types: [], - }, - { - key: "issue.assignee", - description: "Assignee display name", - example: "Alice", - executor_types: [], - }, - { - key: "issue.count", - description: "Total event count", - example: "42", - executor_types: [], - }, - { - key: "issue.user_count", - description: "Unique users affected", - example: "7", - executor_types: [], - }, - { - key: "issue.first_seen", - description: "When the issue was first seen", - example: "2026-05-20T11:30:00Z", - executor_types: [], - }, - { - key: "issue.last_seen", - description: "When the issue was last seen", - example: "2026-05-23T08:14:00Z", - executor_types: [], - }, -]; +/** + * Placeholder tokens available in the issue-watch prompt. + * + * A function rather than a const because `description` is copy — it is rendered + * in the prompt field's tooltip and in the editor's completion list — and a + * module-scope `t()` would freeze it at the boot locale (see docs/i18n.md). + * + * `key` and `example` are NOT copy. `key` is the token the prompt template is + * interpolated against, and each `example` is sample Sentry data: a short ID, an + * issue permalink, a project slug, a level and status drawn from the + * `SentryLevel`/`SentryStatus` unions, a culprit frame, an event count, an ISO + * timestamp. Both are identifiers, so they stay out of the catalog where the + * pseudo-locale would transliterate them into dead pointers. Same contract as + * `linearIssueWatchPlaceholders` in components/linear. + */ +export function sentryIssueWatchPlaceholders(t: TFunction): ScriptPlaceholder[] { + return [ + { + key: "issue.short_id", + description: t("sentry:placeholderShortId"), + example: "PROJ-123", + executor_types: [], + }, + { + key: "issue.title", + description: t("sentry:placeholderIssueTitle"), + example: "TypeError: cannot read property of undefined", + executor_types: [], + }, + { + key: "issue.url", + description: t("sentry:placeholderIssueUrl"), + example: "https://sentry.io/organizations/acme/issues/123/", + executor_types: [], + }, + { + key: "issue.project", + description: t("sentry:placeholderProjectSlug"), + example: "frontend", + executor_types: [], + }, + { + key: "issue.level", + description: t("sentry:placeholderIssueLevel"), + example: "error", + executor_types: [], + }, + { + key: "issue.status", + description: t("sentry:placeholderIssueStatus"), + example: "unresolved", + executor_types: [], + }, + { + key: "issue.culprit", + description: t("sentry:placeholderCulprit"), + example: "app/routes/checkout.tsx in handleSubmit", + executor_types: [], + }, + { + key: "issue.assignee", + description: t("sentry:placeholderAssigneeDisplayName"), + example: "Alice", + executor_types: [], + }, + { + key: "issue.count", + description: t("sentry:placeholderEventCount"), + example: "42", + executor_types: [], + }, + { + key: "issue.user_count", + description: t("sentry:placeholderUserCount"), + example: "7", + executor_types: [], + }, + { + key: "issue.first_seen", + description: t("sentry:placeholderFirstSeen"), + example: "2026-05-20T11:30:00Z", + executor_types: [], + }, + { + key: "issue.last_seen", + description: t("sentry:placeholderLastSeen"), + example: "2026-05-23T08:14:00Z", + executor_types: [], + }, + ]; +} // DEFAULT_SENTRY_ISSUE_WATCH_PROMPT mirrors apps/backend/config/prompts/sentry-issue-watch-default.md. // Kept in sync by hand: the UI shows this when the user clears the field, and // the backend reads the .md when the saved prompt is empty. +// +// Deliberately NOT translated, for the same reason as +// DEFAULT_LINEAR_ISSUE_WATCH_PROMPT: it is seeded into the field, then persisted +// on the watch and sent to the agent verbatim. A watch created under one locale +// would keep that locale's prompt forever, and the agent reads it as an +// instruction rather than as UI copy — it also has to keep matching the backend +// .md, which has no locale at all. export const DEFAULT_SENTRY_ISSUE_WATCH_PROMPT = `You have been assigned a Sentry issue to triage and fix. **Issue:** {{issue.url}} diff --git a/apps/web/components/sentry/sentry-issue-watch-table.tsx b/apps/web/components/sentry/sentry-issue-watch-table.tsx index 4caef036c8..5e3c1bc320 100644 --- a/apps/web/components/sentry/sentry-issue-watch-table.tsx +++ b/apps/web/components/sentry/sentry-issue-watch-table.tsx @@ -12,6 +12,9 @@ import { Badge } from "@kandev/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import type { SentryIssueWatch, SentrySearchFilter } from "@/lib/types/sentry"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { formatRelative } from "@/lib/i18n/formats"; type SentryIssueWatchTableProps = { watches: SentryIssueWatch[]; @@ -27,21 +30,30 @@ type SentryIssueWatchTableProps = { onToggleEnabled: (watch: SentryIssueWatch) => void; }; -function formatLastPolled(dateStr?: string | null): string { - if (!dateStr) return "Never"; - const ts = new Date(dateStr).getTime(); - if (Number.isNaN(ts)) return "Never"; - const diff = Date.now() - ts; - const minutes = Math.floor(diff / 60000); - if (minutes < 1) return "Just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; +// `t` is threaded in rather than read from a hook: this is a plain function, and +// the guard never inspects a return value, so a literal here would survive lint. +// +// The bucket copy now comes from `formatRelative`, which owns the same +// just-now/m/h/d ladder for every surface and reads the active app locale. Two +// consequences, both deliberate and matching the Linear and Jira tables: the +// first bucket reads "just now" rather than "Just now" (it is shared with the +// rest of the app), and an unparseable timestamp renders empty instead of +// "NaNm ago". +function formatLastPolled(t: TFunction, dateStr?: string | null): string { + if (!dateStr) return t("sentry:never"); + return formatRelative(dateStr); } // summarizeFilter renders the structured filter as a short tag-style label // the user can scan at a glance. +// +// Deliberately NOT translated. This is a filter *expression*, not prose: the +// `org:` / `project:` / `env:` / `level:` / `status:` / `period:` / `q:` prefixes +// mirror Sentry's own search syntax, every value is either a slug the user typed +// or a `SentryLevel` / `SentryStatus` union member that must stay byte-identical +// to what is on the wire, and the column is rendered in monospace. Same call as +// the Linear watch table's summary and the Jira table's raw JQL. "(any)" is the +// empty state of that expression, so it stays with it. function summarizeFilter(filter: SentrySearchFilter | undefined): string { if (!filter) return "(any)"; const parts: string[] = []; @@ -76,6 +88,7 @@ function WatchActions({ onReset: (id: string, workspaceId: string) => void; onDelete: (id: string, workspaceId: string) => void; }) { + const { t } = useTranslation(); return (
    @@ -98,7 +111,7 @@ function WatchActions({ )} - {watch.enabled ? "Pause" : "Enable"} + {watch.enabled ? t("sentry:pause") : t("sentry:enable")} @@ -114,7 +127,7 @@ function WatchActions({ - Check now + {t("sentry:checkNow")} @@ -123,7 +136,7 @@ function WatchActions({ size="sm" className="h-7 w-7 p-0 cursor-pointer" data-testid="watch-reset-button" - aria-label="Reset watch" + aria-label={t("sentry:resetWatch")} onClick={(e) => { e.stopPropagation(); onReset(watch.id, watch.workspaceId); @@ -132,7 +145,7 @@ function WatchActions({ - Reset + {t("common:reset")} @@ -148,7 +161,7 @@ function WatchActions({ - Delete + {t("sentry:delete")}
    ); @@ -164,10 +177,11 @@ export function SentryIssueWatchTable({ onReset, onToggleEnabled, }: SentryIssueWatchTableProps) { + const { t } = useTranslation(); if (watches.length === 0) { return (

    - No Sentry watchers configured. Create one to auto-create tasks from filtered issues. + {t("sentry:noWatchersConfigured")}

    ); } @@ -176,12 +190,12 @@ export function SentryIssueWatchTable({ - Instance - Filter - Interval - Last Polled - Status - Actions + {t("sentry:instance")} + {t("sentry:filter")} + {t("sentry:interval")} + {t("sentry:lastPolled")} + {t("common:status")} + {t("sentry:actions")} @@ -204,14 +218,16 @@ export function SentryIssueWatchTable({ {summarizeFilter(watch.filter)} - {Math.round(watch.pollIntervalSeconds / 60)}m + {t("sentry:intervalMinutes", { + count: Math.round(watch.pollIntervalSeconds / 60), + })} - {formatLastPolled(watch.lastPolledAt)} + {formatLastPolled(t, watch.lastPolledAt)} - {watch.enabled ? "Active" : "Paused"} + {watch.enabled ? t("sentry:active") : t("sentry:paused")} diff --git a/apps/web/components/sentry/sentry-issue-watch-throttle-field.tsx b/apps/web/components/sentry/sentry-issue-watch-throttle-field.tsx index 5e3683a493..acbd2a9b91 100644 --- a/apps/web/components/sentry/sentry-issue-watch-throttle-field.tsx +++ b/apps/web/components/sentry/sentry-issue-watch-throttle-field.tsx @@ -4,6 +4,7 @@ import type React from "react"; import { Label } from "@kandev/ui/label"; import { Input } from "@kandev/ui/input"; import { type FormState, parseMaxInflightTasks } from "./sentry-issue-watch-form"; +import { useTranslation } from "react-i18next"; // MaxInflightTasksField caps how many open tasks a watcher may hold at once. // Blank means uncapped; new matches are deferred to the next poll once the cap @@ -15,25 +16,23 @@ export function MaxInflightTasksField({ form: FormState; setForm: React.Dispatch>; }) { + const { t } = useTranslation(); const invalid = parseMaxInflightTasks(form.maxInflightTasks) === "invalid"; return (
    - -

    - Cap on open tasks created by this watcher. Leave blank for no cap. New matches are deferred - to the next poll when the cap is reached. -

    + +

    {t("sentry:maxInflightTasksHelp")}

    setForm((p) => ({ ...p, maxInflightTasks: e.target.value }))} min={1} step={1} - placeholder="(no cap)" + placeholder={t("sentry:noCap")} aria-invalid={invalid} /> {invalid && ( -

    Enter a positive integer or leave blank.

    +

    {t("sentry:enterAPositiveIntegerOrBlank")}

    )}
    ); diff --git a/apps/web/components/sentry/sentry-issue-watchers-section.tsx b/apps/web/components/sentry/sentry-issue-watchers-section.tsx index b2e23bde74..a69ed39a64 100644 --- a/apps/web/components/sentry/sentry-issue-watchers-section.tsx +++ b/apps/web/components/sentry/sentry-issue-watchers-section.tsx @@ -13,6 +13,7 @@ import { ResetWatchDialog, useWatchResetController } from "@/components/watches/ import { SentryIssueWatchTable } from "./sentry-issue-watch-table"; import { SentryIssueWatchDialog } from "./sentry-issue-watch-dialog"; import type { SentryIssueWatch } from "@/lib/types/sentry"; +import { useTranslation } from "react-i18next"; // SentryIssueWatchersSection lists watches for the workspace resolved by its // parent integration page. Creating a watch is locked to that workspace and @@ -26,45 +27,46 @@ type RawActions = { }; function useToastedActions({ create, update, remove, trigger, reset }: RawActions) { + const { t } = useTranslation(); const { toast } = useToast(); const wrappedCreate = useCallback( async (req: Parameters[0]) => { try { await create(req); - toast({ description: "Watcher created", variant: "success" }); + toast({ description: t("sentry:watcherCreated"), variant: "success" }); } catch (err) { - toast({ description: `Create failed: ${String(err)}`, variant: "error" }); + toast({ description: t("sentry:createFailed", { error: String(err) }), variant: "error" }); throw err; } }, - [create, toast], + [create, toast, t], ); const wrappedUpdate = useCallback( async (id: string, workspaceId: string, req: Parameters[2]) => { try { await update(id, workspaceId, req); - toast({ description: "Watcher updated", variant: "success" }); + toast({ description: t("sentry:watcherUpdated"), variant: "success" }); } catch (err) { - toast({ description: `Update failed: ${String(err)}`, variant: "error" }); + toast({ description: t("sentry:updateFailed", { error: String(err) }), variant: "error" }); throw err; } }, - [update, toast], + [update, toast, t], ); const wrappedDelete = useCallback( async (id: string, workspaceId: string) => { - if (!confirm("Delete this Sentry watcher?")) return; + if (!confirm(t("sentry:deleteThisSentryWatcher"))) return; try { await remove(id, workspaceId); - toast({ description: "Watcher deleted", variant: "success" }); + toast({ description: t("sentry:watcherDeleted"), variant: "success" }); } catch (err) { - toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); + toast({ description: t("sentry:deleteFailed", { error: String(err) }), variant: "error" }); } }, - [remove, toast], + [remove, toast, t], ); const wrappedTrigger = useCallback( @@ -72,14 +74,17 @@ function useToastedActions({ create, update, remove, trigger, reset }: RawAction try { const res = await trigger(id, workspaceId); const n = res?.published ?? 0; + // The old copy hedged with "issue(s)". `count` lets i18next pick the + // form, which reads better in English and is the only shape a language + // with more than two plural forms can translate. const description = - n > 0 ? `Found ${n} new issue(s) — tasks will appear shortly.` : "No new issues matched."; + n > 0 ? t("sentry:foundNewIssues", { count: n }) : t("sentry:noNewIssuesMatched"); toast({ description, variant: "success" }); } catch (err) { - toast({ description: `Check failed: ${String(err)}`, variant: "error" }); + toast({ description: t("sentry:checkFailed", { error: String(err) }), variant: "error" }); } }, - [trigger, toast], + [trigger, toast, t], ); const wrappedReset = useCallback( @@ -90,16 +95,16 @@ function useToastedActions({ create, update, remove, trigger, reset }: RawAction toast({ description: n > 0 - ? `Reset complete — deleted ${n} task(s); next poll will re-import matches.` - : "Reset complete — next poll will re-import matches.", + ? t("sentry:resetCompleteDeletedTasks", { count: n }) + : t("sentry:resetCompleteNoTasksDeleted"), variant: "success", }); } catch (err) { - toast({ description: `Reset failed: ${String(err)}`, variant: "error" }); + toast({ description: t("sentry:resetFailed", { error: String(err) }), variant: "error" }); throw err; } }, - [reset, toast], + [reset, toast, t], ); return { @@ -112,15 +117,18 @@ function useToastedActions({ create, update, remove, trigger, reset }: RawAction } export function SentryIssueWatchersSection({ workspaceId }: { workspaceId: string }) { + const { t } = useTranslation(); const { items, loading, create, update, remove, trigger, previewReset, reset } = useSentryIssueWatches(workspaceId); const { instances } = useSentryInstances(workspaceId); const instanceName = useCallback( (id: string) => { + // An em dash is a glyph, not copy — it stays out of the catalog. The + // resolved name is the user's own instance name, also never translated. if (!id) return "—"; - return instances.find((i) => i.id === id)?.name ?? "(unavailable)"; + return instances.find((i) => i.id === id)?.name ?? t("sentry:instanceUnavailable"); }, - [instances], + [instances, t], ); const actions = useToastedActions({ create, update, remove, trigger, reset }); const saveEnabled = useCallback( @@ -163,12 +171,12 @@ export function SentryIssueWatchersSection({ workspaceId }: { workspaceId: strin return ( } - title="Sentry watchers" - description="Poll a Sentry filter and auto-create a Kandev task for each newly-matching issue." + title={t("sentry:sentryWatchers")} + description={t("sentry:sentryWatchersDescription")} action={ } > @@ -200,7 +208,7 @@ export function SentryIssueWatchersSection({ workspaceId }: { workspaceId: strin diff --git a/apps/web/components/sentry/sentry-settings.tsx b/apps/web/components/sentry/sentry-settings.tsx index ef311a036b..e90ad0eb73 100644 --- a/apps/web/components/sentry/sentry-settings.tsx +++ b/apps/web/components/sentry/sentry-settings.tsx @@ -20,6 +20,7 @@ import type { SentryConfig } from "@/lib/types/sentry"; import { SentryInstanceCard } from "./sentry-instance-card"; import { SentryInstanceForm } from "./sentry-instance-form"; import { SentryIssueWatchersSection } from "./sentry-issue-watchers-section"; +import { useTranslation } from "react-i18next"; // EditMode is the mutually-exclusive form state: at most one add-or-edit form // is open at a time. @@ -29,6 +30,7 @@ type EditMode = { kind: "none" } | { kind: "add" } | { kind: "edit"; id: string // request-versioned so a slow load for a previous workspace (or an overlapping // poll) can't clobber newer results. function useInstanceList(workspaceId: string) { + const { t } = useTranslation(); const { toast } = useToast(); const [instances, setInstances] = useState([]); const [loading, setLoading] = useState(true); @@ -45,7 +47,7 @@ function useInstanceList(workspaceId: string) { if (current !== requestId.current) return; if (reportError) { toast({ - description: `Failed to load Sentry instances: ${String(err)}`, + description: t("sentry:failedToLoadInstances", { error: String(err) }), variant: "error", }); } @@ -53,7 +55,7 @@ function useInstanceList(workspaceId: string) { if (current === requestId.current) setLoading(false); } }, - [workspaceId, toast], + [workspaceId, toast, t], ); useEffect(() => { @@ -89,10 +91,11 @@ function InstanceList({ onCancel, onDirtyChange, }: InstanceListProps) { + const { t } = useTranslation(); if (instances.length === 0 && mode.kind !== "add") { return (

    - No Sentry instances yet. Add one to connect this workspace. + {t("sentry:noInstancesYet")}

    ); } @@ -127,9 +130,44 @@ function EnabledPill() { return ; } -export function SentryConnectionSection({ workspaceId }: { workspaceId: string }) { - const { instances, loading, reload } = useInstanceList(workspaceId); +// useDeleteInstance confirms, deletes, and reports the outcome. Split out of +// SentryConnectionSection so that component stays under the file's +// max-lines-per-function limit. +function useDeleteInstance(workspaceId: string, reload: () => Promise) { + const { t } = useTranslation(); const { toast } = useToast(); + return useCallback( + async (instance: SentryConfig) => { + if (!confirm(t("sentry:removeInstanceConfirm", { name: instance.name }))) return; + try { + await deleteSentryInstance(workspaceId, instance.id); + toast({ description: t("sentry:instanceRemoved"), variant: "success" }); + await reload(); + } catch (err) { + const watchCount = sentryInUseWatchCount(err); + if (watchCount !== null) { + // `count` drives i18next's plural selection; writing the "watch" / + // "watches" ending at the call site would make the message + // untranslatable for every language with a different plural rule. + toast({ + description: t("sentry:cantDeleteInstanceInUse", { + name: instance.name, + count: watchCount, + }), + variant: "error", + }); + return; + } + toast({ description: t("sentry:deleteFailed", { error: String(err) }), variant: "error" }); + } + }, + [workspaceId, toast, reload, t], + ); +} + +export function SentryConnectionSection({ workspaceId }: { workspaceId: string }) { + const { t } = useTranslation(); + const { instances, loading, reload } = useInstanceList(workspaceId); const [mode, setMode] = useState({ kind: "none" }); const [formDirty, setFormDirty] = useState(false); @@ -143,28 +181,7 @@ export function SentryConnectionSection({ workspaceId }: { workspaceId: string } await reload(); }, [reload]); - const handleDelete = useCallback( - async (instance: SentryConfig) => { - if (!confirm(`Remove Sentry instance "${instance.name}"?`)) return; - try { - await deleteSentryInstance(workspaceId, instance.id); - toast({ description: "Sentry instance removed", variant: "success" }); - await reload(); - } catch (err) { - const watchCount = sentryInUseWatchCount(err); - if (watchCount !== null) { - const plural = watchCount === 1 ? "watch" : "watches"; - toast({ - description: `Can't delete "${instance.name}": ${watchCount} ${plural} still bound to it. Reassign or remove those watchers first.`, - variant: "error", - }); - return; - } - toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); - } - }, - [workspaceId, toast, reload], - ); + const handleDelete = useDeleteInstance(workspaceId, reload); const canAddInstance = mode.kind === "none" || @@ -173,17 +190,19 @@ export function SentryConnectionSection({ workspaceId }: { workspaceId: string } return ( } - title="Sentry integration" - description="Connect this workspace to Sentry. Add a named instance for each Sentry org or self-hosted host; credentials are stored encrypted server-side." + title={t("sentry:sentryIntegration")} + description={t("sentry:sentryIntegrationDescription")} action={} >

    - Instances + {t("sentry:instances")}

    {loading && instances.length === 0 ? ( -

    Loading…

    +

    + {t("sentry:loadingEllipsis")} +

    ) : ( - Add instance + {t("sentry:addInstance")} )}
    diff --git a/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts b/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts index 7303d8df3c..a9a6a8a8cf 100644 --- a/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts +++ b/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts @@ -128,7 +128,7 @@ const SCREENS: Array<{ name: string; url: string; allow?: string[] }> = [ }, { name: "settings — task actions", url: "/settings/general/task-actions" }, // NOT YET: "settings — integrations github", "… gitlab", "… jira", - // "… linear". Each page's + // "… linear", "… sentry". Each page's // own copy is fully migrated (verified by running this oracle against it — // every string the integration owns renders accented), but the route expands // the Workspaces > Integrations branch of the settings nav, and @@ -140,14 +140,18 @@ const SCREENS: Array<{ name: string; url: string; allow?: string[] }> = [ // in the PR that migrates the settings nav — allowlisting those nav labels here // would hide real misses instead. // - // Two shared components rendered by all four pages are also still English and + // Two shared components rendered by all five pages are also still English and // would have to be migrated (or allowlisted, which is worse) first: // `components/integrations/drafted-integration-enabled-control.tsx` // ("Enabled"/"Disabled") and `components/watcher-repository-fields.tsx` // ("Repository", "Base Branch", "(no repository)"), plus `STEP_DEFAULT_LABEL` - // and `stepPlaceholder`. All four are shared with the un-migrated Sentry and - // Azure DevOps surfaces, plus `components/integrations/settings-section.tsx` - // chrome and the watcher card's own empty/loading states. + // and `stepPlaceholder`. All four are shared with the un-migrated Azure DevOps + // surface, plus `components/integrations/settings-section.tsx` chrome and the + // watcher card's own empty/loading states. + // + // The Sentry run also surfaced `components/integrations/auth-status-banner.tsx` + // ("Authenticated", "· checked ") and `@kandev/ui`'s built-in dialog + // "Close" label — both shared, both out of scope for a per-integration PR. ]; /** diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs index e6b7c38338..2537c9c469 100644 --- a/apps/web/eslint.i18n.options.mjs +++ b/apps/web/eslint.i18n.options.mjs @@ -376,4 +376,40 @@ export const i18nGuardFiles = [ "components/linear/linear-issue-watch-table.tsx", "components/linear/linear-issue-watchers-section.tsx", "components/linear/linear-settings.tsx", + // Settings → Integrations → Sentry: the page and the settings subtree of + // `components/sentry` — the connection section, the per-instance card and + // add/edit form, the Sentry-watcher section with its table, and the watch + // dialog with its filter/throttle/multiselect field modules. The issue task + // surface (`sentry-issue-button`, `sentry-issue-dialog`) and the quick-task + // launcher live in the same directory and are NOT migrated, which is why this + // is a file list rather than `components/sentry/**`. + // + // `sentry-issue-common.tsx` is deliberately absent. The settings subtree + // reaches it only for `levelBadgeClass` / `statusBadgeClass`, which return CSS + // class names and carry no copy; every string in that file belongs to the + // un-migrated issue dialog, so listing it would pull the task surface into + // this PR. + // + // The two `.ts` entries hold no JSX, so `mode: "jsx-only"` never inspects + // them; the entries record that they are migrated, and only the pseudo-locale + // can prove it stays that way. `sentry-issue-watch-placeholders.ts` carries + // copy deliberately left in English because it is PERSISTED and sent to an + // agent verbatim — `DEFAULT_SENTRY_ISSUE_WATCH_PROMPT`, which must also keep + // matching `apps/backend/config/prompts/sentry-issue-watch-default.md`. The + // placeholder `example` values in the same file, and the option `value`s in + // `sentry-issue-watch-form.ts` (the `SentryLevel` / `SentryStatus` unions and + // Sentry's `statsPeriod` tokens), are identifiers rather than copy; the option + // labels there now travel as `labelKey` and resolve at render. + "app/settings/integrations/sentry/**/*.{ts,tsx}", + "components/sentry/sentry-instance-card.tsx", + "components/sentry/sentry-instance-form.tsx", + "components/sentry/sentry-issue-watch-dialog.tsx", + "components/sentry/sentry-issue-watch-filter-fields.tsx", + "components/sentry/sentry-issue-watch-form.ts", + "components/sentry/sentry-issue-watch-multiselect.tsx", + "components/sentry/sentry-issue-watch-placeholders.ts", + "components/sentry/sentry-issue-watch-table.tsx", + "components/sentry/sentry-issue-watch-throttle-field.tsx", + "components/sentry/sentry-issue-watchers-section.tsx", + "components/sentry/sentry-settings.tsx", ]; diff --git a/apps/web/src/locales/en/sentry.json b/apps/web/src/locales/en/sentry.json new file mode 100644 index 0000000000..a50307993b --- /dev/null +++ b/apps/web/src/locales/en/sentry.json @@ -0,0 +1,154 @@ +{ + "actions": "Actions", + "active": "Active", + "addInstance": "Add instance", + "agentProfile": "Agent Profile", + "any": "(any)", + "authToken": "Auth token", + "availablePlaceholders": "Available placeholders:", + "cantDeleteInstanceInUse_one": "Can't delete \"{{name}}\": {{count}} watch still bound to it. Reassign or remove those watchers first.", + "cantDeleteInstanceInUse_other": "Can't delete \"{{name}}\": {{count}} watches still bound to it. Reassign or remove those watchers first.", + "checkFailed": "Check failed: {{error}}", + "checkNow": "Check now", + "connectedAs": "Connected as {{name}}", + "create": "Create", + "createFailed": "Create failed: {{error}}", + "createSentryWatcher": "Create Sentry Watcher", + "delete": "Delete", + "deleteFailed": "Delete failed: {{error}}", + "deleteInstanceAria": "Delete {{name}} Sentry instance", + "deleteThisSentryWatcher": "Delete this Sentry watcher?", + "edit": "Edit", + "editInstanceAria": "Edit {{name}} Sentry instance", + "editSentryWatcher": "Edit Sentry Watcher", + "enable": "Enable", + "enabled": "Enabled", + "enabledHelp": "Pause or resume polling.", + "enterAPositiveIntegerOrBlank": "Enter a positive integer or leave blank.", + "enterNameAndTokenBeforeSaving": "Enter an instance name and auth token before saving.", + "environment": "Environment", + "environmentHelp": "Optional — restrict to one environment.", + "executorProfile": "Executor Profile", + "failedToLoadInstances": "Failed to load Sentry instances: {{error}}", + "fallsBackToStepDefault": "Optional — falls back to step default.", + "filter": "Filter", + "foundNewIssues_one": "Found {{count}} new issue — tasks will appear shortly.", + "foundNewIssues_other": "Found {{count}} new issues — tasks will appear shortly.", + "grantReadAccess": "Grant Read access to these scopes:", + "instance": "Instance", + "instanceNameTaken": "An instance named \"{{name}}\" already exists in this workspace.", + "instanceRemoved": "Sentry instance removed", + "instanceSaved": "Sentry instance saved", + "instanceUnavailable": "(unavailable)", + "instanceUrl": "Instance URL", + "instanceUrlHelp": "Base URL of your Sentry instance. Leave as {{defaultUrl}} for Sentry SaaS, or point it at a self-hosted install (e.g. {{exampleUrl}}).", + "instances": "Instances", + "interval": "Interval", + "intervalMinutes_one": "{{count}}m", + "intervalMinutes_other": "{{count}}m", + "lastPolled": "Last Polled", + "levelDebug": "debug", + "levelError": "error", + "levelFatal": "fatal", + "levelInfo": "info", + "levelWarning": "warning", + "levels": "Levels", + "levelsHelp": "Click to toggle. Matches issues at ANY of the selected levels.", + "loadingEllipsis": "Loading…", + "maxInflightTasks": "Max in-flight tasks", + "maxInflightTasksHelp": "Cap on open tasks created by this watcher. Leave blank for no cap. New matches are deferred to the next poll when the cap is reached.", + "name": "Name", + "nameHelp": "A label for this instance. Must be unique within the workspace.", + "namePlaceholder": "Production, Self-hosted, …", + "never": "Never", + "newInstance": "New Instance", + "newWatcher": "New watcher", + "noCap": "(no cap)", + "noInstancesInWorkspace": "No Sentry instances in this workspace", + "noInstancesYet": "No Sentry instances yet. Add one to connect this workspace.", + "noNewIssuesMatched": "No new issues matched.", + "noOrganizationsAvailable": "No organizations available", + "noProjectsAvailable": "No projects available", + "noProjectsAvailableSentence": "No projects available.", + "noWatchersConfigured": "No Sentry watchers configured. Create one to auto-create tasks from filtered issues.", + "organizationSlug": "Organization slug", + "organizationSlugHelp": "The Sentry org to poll.", + "pasteAnAuthTokenToTest": "Paste an auth token to test the connection", + "pause": "Pause", + "paused": "Paused", + "placeholderAssigneeDisplayName": "Assignee display name", + "placeholderCulprit": "Culprit (function or file)", + "placeholderEventCount": "Total event count", + "placeholderFirstSeen": "When the issue was first seen", + "placeholderIssueLevel": "Issue level", + "placeholderIssueStatus": "Issue status", + "placeholderIssueTitle": "Issue title", + "placeholderIssueUrl": "Sentry issue permalink", + "placeholderLastSeen": "When the issue was last seen", + "placeholderProjectSlug": "Sentry project slug", + "placeholderShortId": "Sentry short ID", + "placeholderUserCount": "Unique users affected", + "pollIntervalHelp": "How often to re-run the search. Minimum 60s, maximum 3600s.", + "pollIntervalSeconds": "Poll Interval (seconds)", + "projectSlug": "Project slug", + "promptFieldHelp": "The prompt sent to the agent for each new issue. Type {{token}} to insert placeholders.", + "projectSlugHelp": "The Sentry project(s) to poll. Select one or more.", + "projectsSelected_one": "{{count}} project selected", + "projectsSelected_other": "{{count}} projects selected", + "query": "Query", + "queryHelp": "Free-text Sentry search query (optional).", + "removeInstanceConfirm": "Remove Sentry instance \"{{name}}\"?", + "requiredTokenScopes": "Required token scopes", + "resetCompleteDeletedTasks_one": "Reset complete — deleted {{count}} task; next poll will re-import matches.", + "resetCompleteDeletedTasks_other": "Reset complete — deleted {{count}} tasks; next poll will re-import matches.", + "resetCompleteNoTasksDeleted": "Reset complete — next poll will re-import matches.", + "resetFailed": "Reset failed: {{error}}", + "resetWatch": "Reset watch", + "saveFailed": "Save failed: {{error}}", + "savedLeaveBlank": "(saved — leave blank to keep the current value)", + "saving": "Saving…", + "scopeEventRead": "Issue & Event — browse issues and run watchers", + "scopeOrgRead": "Organization — resolve the org and list issues", + "scopeProjectRead": "Project — list projects and scope searches", + "searchProjects": "Search projects...", + "selectAnInstance": "Select an instance", + "selectOrganization": "Select organization", + "selectProjects": "Select projects", + "selectWorkflow": "Select workflow", + "selectWorkspace": "Select workspace", + "sentryInstance": "Sentry instance", + "sentryInstanceHelp": "Which Sentry instance this watcher polls. Immutable after creation.", + "sentryIntegration": "Sentry integration", + "sentryIntegrationDescription": "Connect this workspace to Sentry. Add a named instance for each Sentry org or self-hosted host; credentials are stored encrypted server-side.", + "sentryWatcher": "Sentry watcher", + "sentryWatchers": "Sentry watchers", + "sentryWatchersDescription": "Poll a Sentry filter and auto-create a Kandev task for each newly-matching issue.", + "statsPeriod": "Stats period", + "statsPeriodHelp": "How far back to look for matching issues.", + "statsPeriodLast14Days": "Last 14 days", + "statsPeriodLast24Hours": "Last 24 hours", + "statsPeriodLast30Days": "Last 30 days", + "statsPeriodLast7Days": "Last 7 days", + "statsPeriodLastHour": "Last hour", + "statusIgnored": "ignored", + "statusResolved": "resolved", + "statusUnresolved": "unresolved", + "statuses": "Statuses", + "statusesHelp": "Click to toggle. Matches issues at ANY of the selected statuses.", + "taskPrompt": "Task Prompt", + "testConnection": "Test connection", + "testFailed": "Failed: {{error}}", + "testing": "Testing...", + "update": "Update", + "updateFailed": "Update failed: {{error}}", + "useStepDefault": "(use step default)", + "watchDialogDescription": "Poll Sentry with a structured filter and auto-create a Kandev task for each newly-matching issue. Optionally bind a repository so each task runs against that codebase, or leave it unset to run with no repository.", + "watcherCreated": "Watcher created", + "watcherDeleted": "Watcher deleted", + "watcherUpdated": "Watcher updated", + "workflow": "Workflow", + "workflowHelp": "Tasks are created in this workflow.", + "workflowStep": "Workflow Step", + "workflowStepHelp": "Initial step for new tasks.", + "workspaceHelp": "Tasks created by this watcher land in the selected workspace." +} diff --git a/apps/web/src/locales/pseudo/sentry.json b/apps/web/src/locales/pseudo/sentry.json new file mode 100644 index 0000000000..ced4246e7f --- /dev/null +++ b/apps/web/src/locales/pseudo/sentry.json @@ -0,0 +1,154 @@ +{ + "actions": "Àćţĩōńś", + "active": "Àćţĩvē", + "addInstance": "Àďď ĩńśţàńćē", + "agentProfile": "Àĝēńţ Ƥŕōƒĩĺē", + "any": "(àńŷ)", + "authToken": "Àũţĥ ţōķēń", + "availablePlaceholders": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś:", + "cantDeleteInstanceInUse_one": "Ćàń'ţ ďēĺēţē \"{{name}}\": {{count}} ŵàţćĥ śţĩĺĺ ƀōũńď ţō ĩţ. Ŕēàśśĩĝń ōŕ ŕēḿōvē ţĥōśē ŵàţćĥēŕś ƒĩŕśţ.", + "cantDeleteInstanceInUse_other": "Ćàń'ţ ďēĺēţē \"{{name}}\": {{count}} ŵàţćĥēś śţĩĺĺ ƀōũńď ţō ĩţ. Ŕēàśśĩĝń ōŕ ŕēḿōvē ţĥōśē ŵàţćĥēŕś ƒĩŕśţ.", + "checkFailed": "Ćĥēćķ ƒàĩĺēď: {{error}}", + "checkNow": "Ćĥēćķ ńōŵ", + "connectedAs": "Ćōńńēćţēď àś {{name}}", + "create": "Ćŕēàţē", + "createFailed": "Ćŕēàţē ƒàĩĺēď: {{error}}", + "createSentryWatcher": "Ćŕēàţē Śēńţŕŷ Ŵàţćĥēŕ", + "delete": "Ďēĺēţē", + "deleteFailed": "Ďēĺēţē ƒàĩĺēď: {{error}}", + "deleteInstanceAria": "Ďēĺēţē {{name}} Śēńţŕŷ ĩńśţàńćē", + "deleteThisSentryWatcher": "Ďēĺēţē ţĥĩś Śēńţŕŷ ŵàţćĥēŕ?", + "edit": "Ēďĩţ", + "editInstanceAria": "Ēďĩţ {{name}} Śēńţŕŷ ĩńśţàńćē", + "editSentryWatcher": "Ēďĩţ Śēńţŕŷ Ŵàţćĥēŕ", + "enable": "Ēńàƀĺē", + "enabled": "Ēńàƀĺēď", + "enabledHelp": "Ƥàũśē ōŕ ŕēśũḿē ƥōĺĺĩńĝ.", + "enterAPositiveIntegerOrBlank": "Ēńţēŕ à ƥōśĩţĩvē ĩńţēĝēŕ ōŕ ĺēàvē ƀĺàńķ.", + "enterNameAndTokenBeforeSaving": "Ēńţēŕ àń ĩńśţàńćē ńàḿē àńď àũţĥ ţōķēń ƀēƒōŕē śàvĩńĝ.", + "environment": "Ēńvĩŕōńḿēńţ", + "environmentHelp": "Ōƥţĩōńàĺ — ŕēśţŕĩćţ ţō ōńē ēńvĩŕōńḿēńţ.", + "executorProfile": "Ēxēćũţōŕ Ƥŕōƒĩĺē", + "failedToLoadInstances": "Ƒàĩĺēď ţō ĺōàď Śēńţŕŷ ĩńśţàńćēś: {{error}}", + "fallsBackToStepDefault": "Ōƥţĩōńàĺ — ƒàĺĺś ƀàćķ ţō śţēƥ ďēƒàũĺţ.", + "filter": "Ƒĩĺţēŕ", + "foundNewIssues_one": "Ƒōũńď {{count}} ńēŵ ĩśśũē — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.", + "foundNewIssues_other": "Ƒōũńď {{count}} ńēŵ ĩśśũēś — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.", + "grantReadAccess": "Ĝŕàńţ Ŕēàď àććēśś ţō ţĥēśē śćōƥēś:", + "instance": "Ĩńśţàńćē", + "instanceNameTaken": "Àń ĩńśţàńćē ńàḿēď \"{{name}}\" àĺŕēàďŷ ēxĩśţś ĩń ţĥĩś ŵōŕķśƥàćē.", + "instanceRemoved": "Śēńţŕŷ ĩńśţàńćē ŕēḿōvēď", + "instanceSaved": "Śēńţŕŷ ĩńśţàńćē śàvēď", + "instanceUnavailable": "(ũńàvàĩĺàƀĺē)", + "instanceUrl": "Ĩńśţàńćē ŨŔĹ", + "instanceUrlHelp": "Ɓàśē ŨŔĹ ōƒ ŷōũŕ Śēńţŕŷ ĩńśţàńćē. Ĺēàvē àś {{defaultUrl}} ƒōŕ Śēńţŕŷ ŚààŚ, ōŕ ƥōĩńţ ĩţ àţ à śēĺƒ-ĥōśţēď ĩńśţàĺĺ (ē.ĝ. {{exampleUrl}}).", + "instances": "Ĩńśţàńćēś", + "interval": "Ĩńţēŕvàĺ", + "intervalMinutes_one": "{{count}}ḿ", + "intervalMinutes_other": "{{count}}ḿ", + "lastPolled": "Ĺàśţ Ƥōĺĺēď", + "levelDebug": "ďēƀũĝ", + "levelError": "ēŕŕōŕ", + "levelFatal": "ƒàţàĺ", + "levelInfo": "ĩńƒō", + "levelWarning": "ŵàŕńĩńĝ", + "levels": "Ĺēvēĺś", + "levelsHelp": "Ćĺĩćķ ţō ţōĝĝĺē. Ḿàţćĥēś ĩśśũēś àţ ÀŃŶ ōƒ ţĥē śēĺēćţēď ĺēvēĺś.", + "loadingEllipsis": "Ĺōàďĩńĝ…", + "maxInflightTasks": "Ḿàx ĩń-ƒĺĩĝĥţ ţàśķś", + "maxInflightTasksHelp": "Ćàƥ ōń ōƥēń ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ. Ĺēàvē ƀĺàńķ ƒōŕ ńō ćàƥ. Ńēŵ ḿàţćĥēś àŕē ďēƒēŕŕēď ţō ţĥē ńēxţ ƥōĺĺ ŵĥēń ţĥē ćàƥ ĩś ŕēàćĥēď.", + "name": "Ńàḿē", + "nameHelp": "À ĺàƀēĺ ƒōŕ ţĥĩś ĩńśţàńćē. Ḿũśţ ƀē ũńĩqũē ŵĩţĥĩń ţĥē ŵōŕķśƥàćē.", + "namePlaceholder": "Ƥŕōďũćţĩōń, Śēĺƒ-ĥōśţēď, …", + "never": "Ńēvēŕ", + "newInstance": "Ńēŵ Ĩńśţàńćē", + "newWatcher": "Ńēŵ ŵàţćĥēŕ", + "noCap": "(ńō ćàƥ)", + "noInstancesInWorkspace": "Ńō Śēńţŕŷ ĩńśţàńćēś ĩń ţĥĩś ŵōŕķśƥàćē", + "noInstancesYet": "Ńō Śēńţŕŷ ĩńśţàńćēś ŷēţ. Àďď ōńē ţō ćōńńēćţ ţĥĩś ŵōŕķśƥàćē.", + "noNewIssuesMatched": "Ńō ńēŵ ĩśśũēś ḿàţćĥēď.", + "noOrganizationsAvailable": "Ńō ōŕĝàńĩźàţĩōńś àvàĩĺàƀĺē", + "noProjectsAvailable": "Ńō ƥŕōĵēćţś àvàĩĺàƀĺē", + "noProjectsAvailableSentence": "Ńō ƥŕōĵēćţś àvàĩĺàƀĺē.", + "noWatchersConfigured": "Ńō Śēńţŕŷ ŵàţćĥēŕś ćōńƒĩĝũŕēď. Ćŕēàţē ōńē ţō àũţō-ćŕēàţē ţàśķś ƒŕōḿ ƒĩĺţēŕēď ĩśśũēś.", + "organizationSlug": "Ōŕĝàńĩźàţĩōń śĺũĝ", + "organizationSlugHelp": "Ţĥē Śēńţŕŷ ōŕĝ ţō ƥōĺĺ.", + "pasteAnAuthTokenToTest": "Ƥàśţē àń àũţĥ ţōķēń ţō ţēśţ ţĥē ćōńńēćţĩōń", + "pause": "Ƥàũśē", + "paused": "Ƥàũśēď", + "placeholderAssigneeDisplayName": "Àśśĩĝńēē ďĩśƥĺàŷ ńàḿē", + "placeholderCulprit": "Ćũĺƥŕĩţ (ƒũńćţĩōń ōŕ ƒĩĺē)", + "placeholderEventCount": "Ţōţàĺ ēvēńţ ćōũńţ", + "placeholderFirstSeen": "Ŵĥēń ţĥē ĩśśũē ŵàś ƒĩŕśţ śēēń", + "placeholderIssueLevel": "Ĩśśũē ĺēvēĺ", + "placeholderIssueStatus": "Ĩśśũē śţàţũś", + "placeholderIssueTitle": "Ĩśśũē ţĩţĺē", + "placeholderIssueUrl": "Śēńţŕŷ ĩśśũē ƥēŕḿàĺĩńķ", + "placeholderLastSeen": "Ŵĥēń ţĥē ĩśśũē ŵàś ĺàśţ śēēń", + "placeholderProjectSlug": "Śēńţŕŷ ƥŕōĵēćţ śĺũĝ", + "placeholderShortId": "Śēńţŕŷ śĥōŕţ ĨĎ", + "placeholderUserCount": "Ũńĩqũē ũśēŕś àƒƒēćţēď", + "pollIntervalHelp": "Ĥōŵ ōƒţēń ţō ŕē-ŕũń ţĥē śēàŕćĥ. Ḿĩńĩḿũḿ 60ś, ḿàxĩḿũḿ 3600ś.", + "pollIntervalSeconds": "Ƥōĺĺ Ĩńţēŕvàĺ (śēćōńďś)", + "projectSlug": "Ƥŕōĵēćţ śĺũĝ", + "promptFieldHelp": "Ţĥē ƥŕōḿƥţ śēńţ ţō ţĥē àĝēńţ ƒōŕ ēàćĥ ńēŵ ĩśśũē. Ţŷƥē {{token}} ţō ĩńśēŕţ ƥĺàćēĥōĺďēŕś.", + "projectSlugHelp": "Ţĥē Śēńţŕŷ ƥŕōĵēćţ(ś) ţō ƥōĺĺ. Śēĺēćţ ōńē ōŕ ḿōŕē.", + "projectsSelected_one": "{{count}} ƥŕōĵēćţ śēĺēćţēď", + "projectsSelected_other": "{{count}} ƥŕōĵēćţś śēĺēćţēď", + "query": "Qũēŕŷ", + "queryHelp": "Ƒŕēē-ţēxţ Śēńţŕŷ śēàŕćĥ qũēŕŷ (ōƥţĩōńàĺ).", + "removeInstanceConfirm": "Ŕēḿōvē Śēńţŕŷ ĩńśţàńćē \"{{name}}\"?", + "requiredTokenScopes": "Ŕēqũĩŕēď ţōķēń śćōƥēś", + "resetCompleteDeletedTasks_one": "Ŕēśēţ ćōḿƥĺēţē — ďēĺēţēď {{count}} ţàśķ; ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.", + "resetCompleteDeletedTasks_other": "Ŕēśēţ ćōḿƥĺēţē — ďēĺēţēď {{count}} ţàśķś; ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.", + "resetCompleteNoTasksDeleted": "Ŕēśēţ ćōḿƥĺēţē — ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.", + "resetFailed": "Ŕēśēţ ƒàĩĺēď: {{error}}", + "resetWatch": "Ŕēśēţ ŵàţćĥ", + "saveFailed": "Śàvē ƒàĩĺēď: {{error}}", + "savedLeaveBlank": "(śàvēď — ĺēàvē ƀĺàńķ ţō ķēēƥ ţĥē ćũŕŕēńţ vàĺũē)", + "saving": "Śàvĩńĝ…", + "scopeEventRead": "Ĩśśũē & Ēvēńţ — ƀŕōŵśē ĩśśũēś àńď ŕũń ŵàţćĥēŕś", + "scopeOrgRead": "Ōŕĝàńĩźàţĩōń — ŕēśōĺvē ţĥē ōŕĝ àńď ĺĩśţ ĩśśũēś", + "scopeProjectRead": "Ƥŕōĵēćţ — ĺĩśţ ƥŕōĵēćţś àńď śćōƥē śēàŕćĥēś", + "searchProjects": "Śēàŕćĥ ƥŕōĵēćţś...", + "selectAnInstance": "Śēĺēćţ àń ĩńśţàńćē", + "selectOrganization": "Śēĺēćţ ōŕĝàńĩźàţĩōń", + "selectProjects": "Śēĺēćţ ƥŕōĵēćţś", + "selectWorkflow": "Śēĺēćţ ŵōŕķƒĺōŵ", + "selectWorkspace": "Śēĺēćţ ŵōŕķśƥàćē", + "sentryInstance": "Śēńţŕŷ ĩńśţàńćē", + "sentryInstanceHelp": "Ŵĥĩćĥ Śēńţŕŷ ĩńśţàńćē ţĥĩś ŵàţćĥēŕ ƥōĺĺś. Ĩḿḿũţàƀĺē àƒţēŕ ćŕēàţĩōń.", + "sentryIntegration": "Śēńţŕŷ ĩńţēĝŕàţĩōń", + "sentryIntegrationDescription": "Ćōńńēćţ ţĥĩś ŵōŕķśƥàćē ţō Śēńţŕŷ. Àďď à ńàḿēď ĩńśţàńćē ƒōŕ ēàćĥ Śēńţŕŷ ōŕĝ ōŕ śēĺƒ-ĥōśţēď ĥōśţ; ćŕēďēńţĩàĺś àŕē śţōŕēď ēńćŕŷƥţēď śēŕvēŕ-śĩďē.", + "sentryWatcher": "Śēńţŕŷ ŵàţćĥēŕ", + "sentryWatchers": "Śēńţŕŷ ŵàţćĥēŕś", + "sentryWatchersDescription": "Ƥōĺĺ à Śēńţŕŷ ƒĩĺţēŕ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ĩśśũē.", + "statsPeriod": "Śţàţś ƥēŕĩōď", + "statsPeriodHelp": "Ĥōŵ ƒàŕ ƀàćķ ţō ĺōōķ ƒōŕ ḿàţćĥĩńĝ ĩśśũēś.", + "statsPeriodLast14Days": "Ĺàśţ 14 ďàŷś", + "statsPeriodLast24Hours": "Ĺàśţ 24 ĥōũŕś", + "statsPeriodLast30Days": "Ĺàśţ 30 ďàŷś", + "statsPeriodLast7Days": "Ĺàśţ 7 ďàŷś", + "statsPeriodLastHour": "Ĺàśţ ĥōũŕ", + "statusIgnored": "ĩĝńōŕēď", + "statusResolved": "ŕēśōĺvēď", + "statusUnresolved": "ũńŕēśōĺvēď", + "statuses": "Śţàţũśēś", + "statusesHelp": "Ćĺĩćķ ţō ţōĝĝĺē. Ḿàţćĥēś ĩśśũēś àţ ÀŃŶ ōƒ ţĥē śēĺēćţēď śţàţũśēś.", + "taskPrompt": "Ţàśķ Ƥŕōḿƥţ", + "testConnection": "Ţēśţ ćōńńēćţĩōń", + "testFailed": "Ƒàĩĺēď: {{error}}", + "testing": "Ţēśţĩńĝ...", + "update": "Ũƥďàţē", + "updateFailed": "Ũƥďàţē ƒàĩĺēď: {{error}}", + "useStepDefault": "(ũśē śţēƥ ďēƒàũĺţ)", + "watchDialogDescription": "Ƥōĺĺ Śēńţŕŷ ŵĩţĥ à śţŕũćţũŕēď ƒĩĺţēŕ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ĩśśũē. Ōƥţĩōńàĺĺŷ ƀĩńď à ŕēƥōśĩţōŕŷ śō ēàćĥ ţàśķ ŕũńś àĝàĩńśţ ţĥàţ ćōďēƀàśē, ōŕ ĺēàvē ĩţ ũńśēţ ţō ŕũń ŵĩţĥ ńō ŕēƥōśĩţōŕŷ.", + "watcherCreated": "Ŵàţćĥēŕ ćŕēàţēď", + "watcherDeleted": "Ŵàţćĥēŕ ďēĺēţēď", + "watcherUpdated": "Ŵàţćĥēŕ ũƥďàţēď", + "workflow": "Ŵōŕķƒĺōŵ", + "workflowHelp": "Ţàśķś àŕē ćŕēàţēď ĩń ţĥĩś ŵōŕķƒĺōŵ.", + "workflowStep": "Ŵōŕķƒĺōŵ Śţēƥ", + "workflowStepHelp": "Ĩńĩţĩàĺ śţēƥ ƒōŕ ńēŵ ţàśķś.", + "workspaceHelp": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē." +} diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index 6b877fa593..5be437bf6e 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -419,27 +419,27 @@ "diagnosticReviewDescription": "Ţĥĩś ŹĨƤ ćōḿƀĩńēś ũƥ ţō ţĥŕēē ďàŷś ōƒ ƀàćķēńď ĺōĝś ŵĩţĥ ĺōćàĺĺŷ ŕēţàĩńēď ƒŕōńţēńď ćōńśōĺē ēvēńţś ƒŕōḿ ćōńńēćţēď ƀŕōŵśēŕś. Ĩţ ḿàŷ ćōńţàĩń ŨŔĹś, ćōńśōĺē àŕĝũḿēńţś, śţàćķś, ƥàţĥś, ŕũńţĩḿē ḿēţàďàţà, àńď ũśēŕ-vĩśĩƀĺē ēŕŕōŕś.", "diagnosticBundleTitle": "Ƒŕōńţēńď + ƀàćķēńď ďĩàĝńōśţĩć ĺōĝś", "diagnosticBundleDescription": "Ķàńďēv àśķś ŷōũŕ ćōńńēćţēď ƀŕōŵśēŕ ţàƀś ƒōŕ ţĥēĩŕ ƀōũńďēď ţĥŕēē-ďàŷ ćōńśōĺē ĥĩśţōŕŷ, ćōḿƀĩńēś ĩţ ŵĩţĥ ŕēţàĩńēď ƀàćķēńď ĺōĝ ƒĩĺēś, àńď ďōŵńĺōàďś ōńē ŹĨƤ.", - "diagnosticBackendEvents": "Ɓàćķēńď ĺōĝś ĩńćĺũďē śţàŕţũƥ, ĺĩƒēćŷćĺē, ÀƤĨ, ēẋēćũţōŕ, àńď ēŕŕōŕ ēvēńţś ŵŕĩţţēń ƀŷ Ķàńďēv.", - "diagnosticFrontendEvents": "Ƒŕōńţēńď ĺōĝś ĩńćĺũďē ƀŕōŵśēŕ ćōńśōĺē ēŕŕōŕś, ŵàŕńĩńĝś, àńď ďĩàĝńōśţĩć ţōàśţ ŕēƥōŕţś.", + "diagnosticBackendEvents": "Ɓàćķēńď ĺōĝś ĩńćĺũďē śţàŕţũƥ, ĺĩƒēćŷćĺē, ÀƤĨ, ēxēćũţōŕ, àńď ēŕŕōŕ ēvēńţś ŵŕĩţţēń ƀŷ Ķàńďēv.", + "diagnosticFrontendEvents": "Ƒŕōńţēńď ĺōĝś ĩńćĺũďē ƀŕōŵśēŕ ćōńśōĺē ēŕŕōŕś, ŵàŕńĩńĝś, àńď ďĩàĝńōśţĩć ţōàśţ ŕēƥōŕţś ƒŕōḿ ţĥĩś ƀŕōŵśēŕ ƥŕōƒĩĺē.", "diagnosticNoMessages": "Śţàńďàŕď ƀũńďĺēś ďō ńōţ ĩńćĺũďē ćĥàţ ţŕàńśćŕĩƥţś, śēśśĩōń ḿēśśàĝēś, ōŕ àĝēńţ ḿēśśàĝēś.", - "diagnosticIncidentalText": "Ĩńćĩďēńţàĺ ţēẋţ àĺŕēàďŷ ŵŕĩţţēń ĩńţō à ĺōĝ ēńţŕŷ ĩś ńōţ àũţōḿàţĩćàĺĺŷ ŕēďàćţēď.", + "diagnosticIncidentalText": "Ĩńćĩďēńţàĺ ţēxţ àĺŕēàďŷ ŵŕĩţţēń ĩńţō à ĺōĝ ēńţŕŷ ĩś ńōţ àũţōḿàţĩćàĺĺŷ ŕēďàćţēď. Ŕēvĩēŵ ţĥē ŹĨƤ ƀēƒōŕē śĥàŕĩńĝ.", "diagnosticCustomize": "Ćũśţōḿĩźē ƀũńďĺē", "diagnosticCustomizerTitle": "Ćũśţōḿĩźē ďĩàĝńōśţĩć ƀũńďĺē", "diagnosticCustomizerDescription": "Ćĥōōśē ţĥē ēvĩďēńćē ţō ĩńćĺũďē. Ɓàćķēńď àńď ƒŕōńţēńď ĺōĝś àŕē śēĺēćţēď ƀŷ ďēƒàũĺţ, àńď ńōţĥĩńĝ ĩś śàvēď àś à ƥŕēƒēŕēńćē.", "diagnosticSourcesTitle": "Ēvĩďēńćē śōũŕćēś", "diagnosticSourceBackend": "Ɓàćķēńď ĺōĝś", - "diagnosticSourceBackendDescription": "Ŭƥ ţō ţĥŕēē ďàŷś ōƒ ŕēţàĩńēď Ķàńďēv ƀàćķēńď ĺōĝ ƒĩĺēś.", + "diagnosticSourceBackendDescription": "Ũƥ ţō ţĥŕēē ďàŷś ōƒ ŕēţàĩńēď Ķàńďēv ƀàćķēńď ĺōĝ ƒĩĺēś.", "diagnosticSourceFrontend": "Ƒŕōńţēńď ĺōĝś", "diagnosticSourceFrontendDescription": "Ɓōũńďēď ćōńśōĺē ēvēńţś ƒŕōḿ ćōńńēćţēď ƀŕōŵśēŕ ţàƀś.", - "diagnosticSourceRuntime": "Ŕũńţĩḿē ĩńďēẋ", - "diagnosticSourceRuntimeDescription": "Àĺĺōŵ-ĺĩśţēď śēśśĩōń śţàţũś àńď ēẋēćũţōŕ ḿēţàďàţà; ńō ḿēśśàĝēś ōŕ ƥŕōḿƥţś.", + "diagnosticSourceRuntime": "Ŕũńţĩḿē ĩńďēx", + "diagnosticSourceRuntimeDescription": "Àĺĺōŵ-ĺĩśţēď śēśśĩōń śţàţũś àńď ēxēćũţōŕ ḿēţàďàţà; ńō ḿēśśàĝēś ōŕ ƥŕōḿƥţś.", "diagnosticSourceACP": "ÀĆƤ ďēƀũĝ ḿēśśàĝēś", "diagnosticSourceACPDescription": "Ŕàŵ àńď ńōŕḿàĺĩźēď àĝēńţ ƥŕōţōćōĺ ƒŕàḿēś ƒōŕ śēĺēćţēď śēśśĩōńś.", "diagnosticSelectSessions": "Śēĺēćţ ÀĆƤ śēśśĩōńś", - "diagnosticSessionsDescription_one": "Ćĥōōśē ũƥ ţō {{count}} ēĺĩĝĩƀĺē śēśśĩōń. Ŭńàvàĩĺàƀĺē śēśśĩōńś ćàńńōţ ƀē ćōĺĺēćţēď.", - "diagnosticSessionsDescription_other": "Ćĥōōśē ũƥ ţō {{count}} ēĺĩĝĩƀĺē śēśśĩōńś. Ŭńàvàĩĺàƀĺē śēśśĩōńś ćàńńōţ ƀē ćōĺĺēćţēď.", + "diagnosticSessionsDescription_one": "Ćĥōōśē ũƥ ţō {{count}} ēĺĩĝĩƀĺē śēśśĩōń. Ũńàvàĩĺàƀĺē śēśśĩōńś ćàńńōţ ƀē ćōĺĺēćţēď.", + "diagnosticSessionsDescription_other": "Ćĥōōśē ũƥ ţō {{count}} ēĺĩĝĩƀĺē śēśśĩōńś. Ũńàvàĩĺàƀĺē śēśśĩōńś ćàńńōţ ƀē ćōĺĺēćţēď.", "diagnosticSessionsLoading": "Ĺōàďĩńĝ ēĺĩĝĩƀĺē śēśśĩōńś…", - "diagnosticSessionUnavailable": "Ŭńàvàĩĺàƀĺē", + "diagnosticSessionUnavailable": "Ũńàvàĩĺàƀĺē", "diagnosticSessionRetained": "Ĥōśţ-ŕēţàĩńēď", "diagnosticSessionReachable": "Ŕēàćĥàƀĺē", "diagnosticSelectedSessions_one": "{{count}} śēśśĩōń śēĺēćţēď", @@ -450,8 +450,8 @@ "diagnosticSourceRequired": "Śēĺēćţ àţ ĺēàśţ ōńē ēvĩďēńćē śōũŕćē.", "diagnosticACPSelectionRequired": "Śēĺēćţ àţ ĺēàśţ ōńē ēĺĩĝĩƀĺē ÀĆƤ śēśśĩōń.", "diagnosticACPSelectionLimit": "Śēĺēćţ ńō ḿōŕē ţĥàń ţēń ÀĆƤ śēśśĩōńś.", - "diagnosticACPWarning": "ÀĆƤ ďēƀũĝ ḿēśśàĝēś ćàń ćōńţàĩń ƥŕōḿƥţś, ŕēśƥōńśēś, ţōōĺ ćàĺĺś, ƒĩĺē ćōńţēńţ, ḾĆƤ ďàţà, ēńvĩŕōńḿēńţ vàĺũēś, àńď śēćŕēţś.", - "diagnosticACPNoSessions": "ÀĆƤ ďōŵńĺōàď ĩś àvàĩĺàƀĺē ĩń ďēƀũĝ ḿōďē, ƀũţ ţĥĩś àććōũńţ ĥàś ńō ēĺĩĝĩƀĺē śēśśĩōńś.", + "diagnosticACPWarning": "ÀĆƤ ďēƀũĝ ḿēśśàĝēś ćàń ćōńţàĩń ƥŕōḿƥţś, ŕēśƥōńśēś, ţōōĺ ćàĺĺś, ƒĩĺē ćōńţēńţś, ḾĆƤ ďàţà, ēńvĩŕōńḿēńţ-ďēŕĩvēď vàĺũēś, àńď śēćŕēţś. Ŕēvĩēŵ ţĥĩś ŹĨƤ ćàŕēƒũĺĺŷ ƀēƒōŕē śĥàŕĩńĝ.", + "diagnosticACPNoSessions": "ÀĆƤ ďōŵńĺōàď ĩś àvàĩĺàƀĺē ĩń ďēƀũĝ ḿōďē, ƀũţ ţĥĩś àććōũńţ ĥàś ńō ēĺĩĝĩƀĺē śēśśĩōńś ţō ĩńćĺũďē.", "diagnosticBundleCancel": "Ćàńćēĺ", "diagnosticBundleCreate": "Ćŕēàţē ƀũńďĺē", "diagnosticBundlePrepareError": "Ţĥē ďĩàĝńōśţĩć ƀũńďĺē ćōũĺď ńōţ ƀē ƥŕēƥàŕēď.",