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
40 changes: 37 additions & 3 deletions apps/electric-sync/src/routes/shape.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ import { SyncConfig } from "../config"
*/

// Server-pinned shape whitelist. Every shape is additionally org-scoped below;
// `extraWhere` narrows the synced rows further (immutable — changing it is a new
// shape name + full re-sync, so version the name if it must ever change).
// `extraWhere` narrows the synced rows further and `columns` restricts which
// columns Electric streams to the browser (drop encrypted secrets / large jsonb
// blobs — the client never needs them, and they must not leave the server). Both
// are immutable — changing either is a new shape name + full re-sync, so version
// the name if it must ever change. When `columns` is set it MUST include the
// table's primary-key column(s) (Electric requires the PK in the projection).
const SHAPES = {
dashboards: { table: "dashboards" },
alert_rules: { table: "alert_rules" },
Expand All @@ -33,7 +37,33 @@ const SHAPES = {
error_issues: { table: "error_issues", extraWhere: `"archived_at" IS NULL` },
actors: { table: "actors" },
open_error_incidents: { table: "error_incidents", extraWhere: `"status" = 'open'` },
} as const satisfies Record<string, { readonly table: string; readonly extraWhere?: string }>
// `config_json` holds only public config (summary / channel label / hazel
// metadata); the encrypted webhook secrets live in separate `secret_*` columns
// that MUST NOT reach the browser, so the projection drops them (and the
// unused `created_by`/`updated_by`). The PK `id` is required in the projection.
alert_destinations: {
table: "alert_destinations",
columns: [
"id",
"org_id",
"name",
"type",
"enabled",
"config_json",
"last_tested_at",
"last_test_error",
"created_at",
"updated_at",
],
},
// Uptime-probe history — already pruned server-side to 24h + a 10k-per-target
// cap (see ScrapeTargetsService.pruneChecks), so the whole per-org shape stays
// bounded. No secrets in this table.
scrape_target_checks: { table: "scrape_target_checks" },
} as const satisfies Record<
string,
{ readonly table: string; readonly extraWhere?: string; readonly columns?: ReadonlyArray<string> }
>

export type ShapeName = keyof typeof SHAPES

Expand Down Expand Up @@ -136,6 +166,10 @@ export const buildUpstreamShapeUrl = (args: {
)
url.searchParams.set("params[1]", args.orgId)

// Column projection is pinned server-side too — a shape that drops secret /
// oversized columns must never let the client widen it back to `SELECT *`.
if ("columns" in def && def.columns) url.searchParams.set("columns", def.columns.join(","))

// Electric Cloud source credentials (absent when self-hosting Electric).
if (args.sourceId) url.searchParams.set("source_id", args.sourceId)
if (args.secret) url.searchParams.set("secret", args.secret)
Expand Down
30 changes: 30 additions & 0 deletions apps/electric-sync/src/routes/shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,36 @@ describe("buildUpstreamShapeUrl", () => {
assert.isNull(params.get("replica"))
})

it("pins a shape's column projection and drops the secret columns", () => {
const { params } = parse(buildUpstreamShapeUrl({ ...base, shape: "alert_destinations" }))
const columns = params.get("columns")?.split(",") ?? []
// PK + org scope must be present for Electric.
assert.include(columns, "id")
assert.include(columns, "org_id")
// The public config the browser renders is allowed…
assert.include(columns, "config_json")
// …but the encrypted secret columns must never be projected.
assert.notInclude(columns, "secret_ciphertext")
assert.notInclude(columns, "secret_iv")
assert.notInclude(columns, "secret_tag")
})

it("omits the columns param for shapes that sync every column", () => {
const { params } = parse(buildUpstreamShapeUrl({ ...base, shape: "scrape_target_checks" }))
assert.isNull(params.get("columns"))
})

it("never lets the client widen a column-restricted shape back to secrets", () => {
const malicious = new URLSearchParams()
malicious.set("columns", "id,org_id,secret_ciphertext,secret_iv,secret_tag")
const { params } = parse(
buildUpstreamShapeUrl({ ...base, shape: "alert_destinations", clientParams: malicious }),
)
const columns = params.get("columns")?.split(",") ?? []
assert.notInclude(columns, "secret_ciphertext")
assert.include(columns, "config_json")
})

it("adds Electric Cloud source credentials only when provided", () => {
const without = parse(buildUpstreamShapeUrl({ ...base, shape: "dashboards" })).params
assert.isNull(without.get("source_id"))
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/atoms/dashboard-history-atoms.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Atom } from "@/lib/effect-atom"
import type { DashboardVersionId } from "@maple/domain/http"

export interface PreviewedVersion {
readonly versionId: string
readonly versionId: DashboardVersionId
readonly versionNumber: number
readonly createdAt: string
readonly createdBy: string
Expand Down
48 changes: 48 additions & 0 deletions apps/web/src/components/alerts/alert-create-page-content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest"

import { Result } from "@/lib/effect-atom"
import { deriveInitialRuleDraft } from "./alert-create-page-content"

/**
* The starter-template deep link from the overview empty state. With no ruleId /
* chart / dashboard params, `deriveInitialRuleDraft` reaches the template branch
* before the rules/dashboards results ever matter, so `Result.initial()` stands
* in for both.
*/
const loading = Result.initial()

describe("deriveInitialRuleDraft — template deep link", () => {
// `low_apdex` differs from the blank defaults on signal, comparator, and
// threshold, so a pass proves the template was applied (not just defaults).
it("pre-applies a known template and skips the first-touch overlay", () => {
const draft = deriveInitialRuleDraft({
search: { template: "low_apdex" },
chartContext: undefined,
rulesResult: loading,
dashboardsResult: loading,
})

expect(draft.form.signalType).toBe("apdex")
expect(draft.form.comparator).toBe("lt")
expect(draft.form.threshold).toBe("0.8")
expect(draft.form.apdexThresholdMs).toBe("500")
expect(draft.form.name).toBe("Low Apdex score")
expect(draft.showTemplatesInitially).toBe(false)
expect(draft.key).toBe("new:template:low_apdex")
})

it("falls through to a blank draft (overlay opens) for an unknown template id", () => {
const draft = deriveInitialRuleDraft({
search: { template: "not-a-real-template" },
chartContext: undefined,
rulesResult: loading,
dashboardsResult: loading,
})

expect(draft.form.signalType).toBe("error_rate")
expect(draft.form.name).toBe("")
// No serviceName + unknown template → the overlay still leads the flow.
expect(draft.showTemplatesInitially).toBe(true)
expect(draft.key).toBe("new:blank")
})
})
26 changes: 21 additions & 5 deletions apps/web/src/components/alerts/alert-create-page-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import type { AlertDestinationDocument, AlertRuleDocument, DashboardDocument } f
import { AlertCreateFormSurface } from "@/components/alerts/alert-create-form-surface"
import { useAutocompleteValuesContext } from "@/hooks/use-autocomplete-values"
import { defaultRuleForm, ruleToFormState, type RuleFormState } from "@/lib/alerts/form-utils"
import { ALERT_TEMPLATES, applyTemplate } from "@/lib/alerts/templates"
import { decodeAlertChartFromSearchParam, type AlertChartContext } from "@/lib/alerts/widget-chart-param"
import {
createWidgetAlertPrefill,
resolveWidgetAlertPrefill,
type WidgetAlertPrefillNotice,
} from "@/lib/alerts/widget-prefill"
import { useAlertDestinationsList } from "@/hooks/use-alerts-list"
import { Atom, Result, useAtomValue } from "@/lib/effect-atom"
import { MapleApiAtomClient } from "@/lib/services/common/atom-client"

Expand All @@ -21,6 +23,7 @@ type AlertCreateSearchValue = {
dashboardId?: string
widgetId?: string
chart?: string
template?: string
}

type InitialRuleDraft = {
Expand Down Expand Up @@ -48,16 +51,13 @@ export function AlertCreatePageContent() {
const needsDashboards =
!search.ruleId && chartContext == null && Boolean(search.dashboardId || search.widgetId)

const destinationsQueryAtom = MapleApiAtomClient.query("alerts", "listDestinations", {
reactivityKeys: ["alertDestinations"],
})
const rulesQueryAtom = MapleApiAtomClient.query("alerts", "listRules", {
reactivityKeys: ["alertRules"],
})
const dashboardsQueryAtom = MapleApiAtomClient.query("dashboards", "list", {
reactivityKeys: ["dashboards"],
})
const destinationsResult = useAtomValue(destinationsQueryAtom)
const { result: destinationsResult } = useAlertDestinationsList()
const rulesResult = useAtomValue(rulesQueryAtom)
const dashboardsResult = useAtomValue(needsDashboards ? dashboardsQueryAtom : idleDashboardsAtom)

Expand Down Expand Up @@ -93,7 +93,7 @@ export function AlertCreatePageContent() {
)
}

function deriveInitialRuleDraft({
export function deriveInitialRuleDraft({
search,
chartContext,
rulesResult,
Expand Down Expand Up @@ -213,6 +213,22 @@ function deriveInitialRuleDraft({
}
}

// Starter-template deep link from the overview empty state — pre-apply the
// preset and skip the first-touch overlay. An unknown id falls through to the
// blank draft below (overlay still opens).
if (search.template) {
const template = ALERT_TEMPLATES.find((t) => t.id === search.template)
if (template) {
return {
key: `new:template:${template.id}`,
form: applyTemplate(template, base),
prefillNotices: [],
editingRule: null,
showTemplatesInitially: false,
}
}
}

return {
key: `new:${search.serviceName ?? "blank"}`,
form: base,
Expand Down
132 changes: 132 additions & 0 deletions apps/web/src/components/alerts/overview/alerts-empty-state.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { Link } from "@tanstack/react-router"

import { Button } from "@maple/ui/components/ui/button"
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@maple/ui/components/ui/empty"
import { cn } from "@maple/ui/utils"

import { PlusIcon } from "@/components/icons"
import { ALERT_TEMPLATES, type AlertTemplate } from "@/lib/alerts/templates"

/**
* First-run empty state for the alerts overview (zero rules). Replaces the
* generic bell-badge with an operator-console "quiet monitor" readout — a live
* signal held below a not-yet-placed threshold — then surfaces the five real
* starter templates as brand-colored, one-click deep links into a pre-filled
* create form. Non-admins get the readout + a nudge, no create affordances.
*
* The tile hues mirror `BUILTIN_SIGNAL_OPTIONS` on the create form
* (signal-and-threshold-section.tsx) so a template reads with the same color it
* paints on the live chart. Kept here rather than on the pure `ALERT_TEMPLATES`
* module so `templates.ts` stays React-free beyond its display icons.
*/
const TILE_TONE: Record<AlertTemplate["id"], { glyph: string; hoverBorder: string }> = {
high_error_rate: { glyph: "bg-chart-error/10 text-chart-error", hoverBorder: "hover:border-chart-error/50" },
slow_p95: { glyph: "bg-chart-p95/10 text-chart-p95", hoverBorder: "hover:border-chart-p95/50" },
slow_p99: { glyph: "bg-chart-p99/10 text-chart-p99", hoverBorder: "hover:border-chart-p99/50" },
low_apdex: { glyph: "bg-chart-apdex/10 text-chart-apdex", hoverBorder: "hover:border-chart-apdex/50" },
throughput_drop: {
glyph: "bg-chart-throughput/10 text-chart-throughput",
hoverBorder: "hover:border-chart-throughput/50",
},
}

export function AlertsEmptyState({ isAdmin, serviceName }: { isAdmin: boolean; serviceName?: string }) {
return (
<Empty className="py-12">
<QuietMonitor />
<EmptyHeader>
<EmptyTitle>No rules are watching yet</EmptyTitle>
<EmptyDescription>
{isAdmin
? "A threshold rule opens an incident the moment a signal crosses it. Start from a common one:"
: "Ask an admin to create the first alert rule."}
</EmptyDescription>
</EmptyHeader>

{isAdmin && (
<div className="flex w-full max-w-3xl flex-col items-center gap-4">
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{ALERT_TEMPLATES.map((template) => (
<TemplateTile key={template.id} template={template} serviceName={serviceName} />
))}
</div>
<Button
variant="ghost"
size="sm"
render={<Link to="/alerts/create" search={{ serviceName }} />}
>
<PlusIcon size={14} />
Start from scratch
</Button>
</div>
)}
</Empty>
)
}

function TemplateTile({ template, serviceName }: { template: AlertTemplate; serviceName?: string }) {
const Icon = template.icon
const tone = TILE_TONE[template.id]
return (
<Link
to="/alerts/create"
search={{ template: template.id, serviceName }}
className={cn(
"group flex flex-col gap-1.5 rounded-lg border bg-card p-3 text-left transition-colors",
"hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
tone.hoverBorder,
)}
>
<div className="flex items-center gap-2">
<span
className={cn("flex size-6 shrink-0 items-center justify-center rounded-md", tone.glyph)}
>
<Icon size={13} />
</span>
<span className="font-medium text-sm">{template.title}</span>
</div>
<code className="font-mono text-[11px] text-muted-foreground">{template.summary}</code>
</Link>
)
}

/**
* The signature graphic: a live signal (muted, with a pulsing leading edge)
* running steadily below a dashed amber threshold that hasn't been placed yet —
* the exact thing a rule adds. The threshold reuses `.infra-ref-line` (dashed
* draw-in) and the leading dot reuses `.infra-pulse`; both idioms already carry
* their own `prefers-reduced-motion` guards from styles.css.
*/
function QuietMonitor() {
return (
<div className="relative w-full max-w-[300px] overflow-hidden rounded-lg border bg-card/50 px-4 py-3">
<svg viewBox="0 0 300 84" className="w-full" role="img" aria-label="No threshold is watching yet">
<title>A steady signal running below an unset alert threshold</title>
{/* Dashed amber threshold — the line a rule would place. */}
<g className="infra-ref-line">
<line x1="10" y1="26" x2="290" y2="26" className="stroke-primary" strokeWidth="1.5" opacity="0.55" />
</g>
{/* Live, steady signal well below the threshold. */}
<polyline
points="10,60 30,58 48,61 66,57 86,60 104,58 124,61 142,59 160,57 178,60 196,58 214,61 232,58 250,60 268,58 290,59"
fill="none"
className="stroke-muted-foreground"
strokeWidth="1.75"
strokeLinecap="square"
strokeLinejoin="round"
opacity="0.7"
/>
{/* Pulsing leading edge — signals are flowing, nothing is watching them. */}
<circle
cx="290"
cy="59"
r="4"
className="infra-pulse fill-primary"
style={{ transformBox: "fill-box", transformOrigin: "center" }}
opacity="0.5"
/>
<circle cx="290" cy="59" r="2.5" className="fill-primary" />
</svg>
</div>
)
}
Loading
Loading