Skip to content

Commit 2ddad42

Browse files
Console 2045 UI for enabling/disabling a metric alert rule (#8174)
1 parent 1eae0eb commit 2ddad42

9 files changed

Lines changed: 251 additions & 12 deletions

File tree

packages/web/app/.ladle/vite.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,12 @@ import tailwindcss from '@tailwindcss/vite';
44

55
export default defineConfig({
66
plugins: [tsconfigPaths(), tailwindcss()],
7+
// Ladle pulls Vite 6, but the repo pins esbuild 0.28 (pnpm override). That pairing
8+
// makes esbuild treat modern syntax (destructuring, etc.) as unsupported and try to
9+
// down-level it while pre-bundling deps, which it can't...forcing an esnext target
10+
// tells esbuild everything is supported, so it stops lowering.
11+
optimizeDeps: {
12+
esbuildOptions: { target: 'esnext' },
13+
},
14+
esbuild: { target: 'esnext' },
715
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useState } from 'react';
2+
import type { Story, StoryDefault } from '@ladle/react';
3+
import { Switch } from './switch';
4+
5+
export default {
6+
title: 'Base / Switch',
7+
} satisfies StoryDefault;
8+
9+
export const Default: Story = () => {
10+
const [checked, setChecked] = useState(false);
11+
return (
12+
<div className="flex items-center gap-3 p-8">
13+
<Switch checked={checked} onCheckedChange={setChecked} />
14+
<span className="text-neutral-11 text-sm">{checked ? 'On' : 'Off'}</span>
15+
</div>
16+
);
17+
};
18+
19+
export const Sizes: Story = () => {
20+
const [values, setValues] = useState({ small: false, standard: true });
21+
return (
22+
<div className="flex items-center gap-6 p-8">
23+
{(['small', 'standard'] as const).map(size => (
24+
<div key={size} className="flex items-center gap-2">
25+
<Switch
26+
size={size}
27+
checked={values[size]}
28+
onCheckedChange={v => setValues(prev => ({ ...prev, [size]: v }))}
29+
/>
30+
<span className="text-neutral-11 text-sm">{size}</span>
31+
</div>
32+
))}
33+
</div>
34+
);
35+
};
36+
37+
export const Disabled: Story = () => (
38+
<div className="flex items-center gap-6 p-8">
39+
<div className="flex items-center gap-2">
40+
<Switch disabled checked={false} />
41+
<span className="text-neutral-8 text-sm">Disabled off</span>
42+
</div>
43+
<div className="flex items-center gap-2">
44+
<Switch disabled checked />
45+
<span className="text-neutral-8 text-sm">Disabled on</span>
46+
</div>
47+
</div>
48+
);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { cva, type VariantProps } from 'class-variance-authority';
2+
import { cn } from '@/lib/utils';
3+
import { Switch as BaseSwitch } from '@base-ui/react/switch';
4+
5+
const switchRootVariants = cva(
6+
'data-[unchecked]:bg-neutral-6 relative inline-flex shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-2 disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:bg-success_80',
7+
{
8+
variants: {
9+
size: {
10+
standard: 'h-5 w-10 p-0.5 ',
11+
small: 'h-2.5 w-6',
12+
},
13+
},
14+
defaultVariants: {
15+
size: 'standard',
16+
},
17+
},
18+
);
19+
20+
const switchThumbVariants = cva(
21+
'pointer-events-none block rounded-full bg-neutral-12 shadow-sm transition-transform',
22+
{
23+
variants: {
24+
size: {
25+
standard: 'size-4 data-[checked]:translate-x-5',
26+
small: 'size-[13px] data-[checked]:translate-x-[11px]',
27+
},
28+
},
29+
defaultVariants: {
30+
size: 'standard',
31+
},
32+
},
33+
);
34+
35+
export function Switch({
36+
size,
37+
className,
38+
...props
39+
}: Omit<BaseSwitch.Root.Props, 'className'> &
40+
VariantProps<typeof switchRootVariants> & { className?: string }) {
41+
return (
42+
<BaseSwitch.Root className={cn(switchRootVariants({ size }), className)} {...props}>
43+
<BaseSwitch.Thumb className={switchThumbVariants({ size })} />
44+
</BaseSwitch.Root>
45+
);
46+
}

packages/web/app/src/components/target/alerts/alert-conditions-panel.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import { formatDuration } from '@/lib/hooks/use-formatted-duration';
2525
import { Link } from '@tanstack/react-router';
2626
import { AlertForm, ruleToFormDefaults } from './alert-form';
27+
import { AlertRuleEnabledToggle } from './alert-rule-enabled-toggle';
2728
import { DeleteRuleConfirmationDialog } from './delete-rule-confirmation-dialog';
2829

2930
const TYPE_CATEGORY: Record<MetricAlertRuleType, string> = {
@@ -107,6 +108,7 @@ export type AlertConditionsPanelProps = {
107108
type: MetricAlertRuleType;
108109
metric?: string | null;
109110
severity: MetricAlertRuleSeverity;
111+
enabled: boolean;
110112
direction: string;
111113
thresholdType: MetricAlertRuleThresholdType;
112114
thresholdValue: number;
@@ -132,7 +134,7 @@ export type AlertConditionsPanelProps = {
132134

133135
function RelativeTimestamp({ iso }: { iso: string }) {
134136
return (
135-
<span className="text-neutral-12 inline-flex items-center gap-1 font-mono text-[11px]">
137+
<span className="text-neutral-12 inline-flex items-center gap-1 font-mono text-[10px]">
136138
{formatDistanceToNow(new Date(iso), { addSuffix: true })}
137139
<TooltipProvider delayDuration={100}>
138140
<Tooltip>
@@ -215,7 +217,7 @@ export function AlertConditionsPanel({
215217

216218
return (
217219
<div className="border-neutral-5 bg-neutral-2 space-y-6 border-l px-5 py-3">
218-
<h2 className="text-neutral-12 text-sm font-semibold">Alert conditions</h2>
220+
<h2 className="text-neutral-12 mb-2 block text-sm font-semibold">Alert conditions</h2>
219221

220222
<DescriptionList
221223
rows={[
@@ -278,6 +280,23 @@ export function AlertConditionsPanel({
278280
]}
279281
/>
280282

283+
<div className="border-neutral-5 flex items-center justify-between border-y py-4">
284+
<span className="flex flex-col gap-0.5">
285+
<span className="text-neutral-12 text-sm font-medium">Alert status</span>
286+
<span className="text-neutral-10 text-xs">
287+
{rule.enabled
288+
? 'Evaluating conditions and sending notifications'
289+
: "Paused (conditions aren't evaluated)"}
290+
</span>
291+
</span>
292+
<AlertRuleEnabledToggle
293+
ruleId={rule.id}
294+
enabled={rule.enabled}
295+
organizationSlug={organizationSlug}
296+
projectSlug={projectSlug}
297+
/>
298+
</div>
299+
281300
<div className="flex items-center gap-2">
282301
<ModifyAlertSheet
283302
rule={rule}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useMutation } from 'urql';
2+
import { Switch } from '@/components/base/switch/switch';
3+
import { useToast } from '@/components/ui/use-toast';
4+
import { graphql } from '@/gql';
5+
6+
const AlertRuleEnabledToggle_Mutation = graphql(`
7+
mutation AlertRuleEnabledToggle_Mutation($input: UpdateMetricAlertRuleInput!) {
8+
updateMetricAlertRule(input: $input) {
9+
ok {
10+
updatedMetricAlertRule {
11+
id
12+
enabled
13+
updatedAt
14+
}
15+
}
16+
error {
17+
message
18+
}
19+
}
20+
}
21+
`);
22+
23+
export function AlertRuleEnabledToggle(props: {
24+
ruleId: string;
25+
enabled: boolean;
26+
organizationSlug: string;
27+
projectSlug: string;
28+
className?: string;
29+
}) {
30+
const [, mutate] = useMutation(AlertRuleEnabledToggle_Mutation);
31+
const { toast } = useToast();
32+
33+
return (
34+
<Switch
35+
className={props.className}
36+
checked={props.enabled}
37+
aria-label={props.enabled ? 'Disable alert rule' : 'Enable alert rule'}
38+
onClick={e => e.stopPropagation()}
39+
onCheckedChange={checked =>
40+
void mutate({
41+
input: {
42+
project: {
43+
bySelector: {
44+
organizationSlug: props.organizationSlug,
45+
projectSlug: props.projectSlug,
46+
},
47+
},
48+
ruleId: props.ruleId,
49+
enabled: checked,
50+
},
51+
}).then(result => {
52+
const message =
53+
result.error?.message ?? result.data?.updateMetricAlertRule.error?.message;
54+
if (message) {
55+
toast({
56+
variant: 'destructive',
57+
title: checked ? 'Enable alert rule failed.' : 'Disable alert rule failed.',
58+
description: message,
59+
});
60+
}
61+
})
62+
}
63+
/>
64+
);
65+
}

packages/web/app/src/lib/urql-cache.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ import {
2727
type DeleteTokensDocument,
2828
} from '@/pages/target-settings';
2929
import { ResultOf, VariablesOf } from '@graphql-typed-document-node/core';
30-
import { Cache, QueryInput, UpdateResolver } from '@urql/exchange-graphcache';
30+
import {
31+
Cache,
32+
OptimisticMutationResolver,
33+
QueryInput,
34+
UpdateResolver,
35+
} from '@urql/exchange-graphcache';
3136

3237
const TargetsDocument = graphql(`
3338
query targets($selector: ProjectSelectorInput!) {
@@ -399,8 +404,15 @@ const addMetricAlertRule: TypedDocumentNodeUpdateResolver<
399404
};
400405

401406
const updateMetricAlertRule: UpdateResolver = (_result, args, cache) => {
402-
const ruleId = (args as { input?: { ruleId?: string } } | null)?.input?.ruleId;
403-
if (!ruleId) return;
407+
const input = (args as { input?: Record<string, unknown> } | null)?.input;
408+
const ruleId = input?.ruleId as string | undefined;
409+
if (!input || !ruleId) return;
410+
// Skip for a pure enable/disable toggle: the mutation returns the new `enabled`
411+
// so graphcache merges it in place; invalidating would evict the entity and flash a refetch.
412+
const isEnabledOnlyToggle = Object.keys(input).every(
413+
key => key === 'project' || key === 'ruleId' || key === 'enabled',
414+
);
415+
if (isEnabledOnlyToggle) return;
404416
cache.invalidate({ __typename: 'MetricAlertRule', id: ruleId });
405417
};
406418

@@ -425,3 +437,31 @@ export const Mutation = {
425437
addMetricAlertRule,
426438
updateMetricAlertRule,
427439
};
440+
441+
const updateMetricAlertRuleOptimistic: OptimisticMutationResolver = args => {
442+
const input = (args as { input?: Record<string, unknown> }).input;
443+
const ruleId = input?.ruleId as string | undefined;
444+
if (!input || !ruleId) return null;
445+
// Only the dedicated enable/disable toggle is safe to flip optimistically
446+
const isEnabledOnlyToggle = Object.keys(input).every(
447+
key => key === 'project' || key === 'ruleId' || key === 'enabled',
448+
);
449+
if (!isEnabledOnlyToggle) return null;
450+
return {
451+
__typename: 'UpdateMetricAlertRuleResult',
452+
error: null,
453+
ok: {
454+
__typename: 'UpdateMetricAlertRuleOk',
455+
updatedMetricAlertRule: {
456+
__typename: 'MetricAlertRule',
457+
id: ruleId,
458+
enabled: input.enabled as boolean,
459+
updatedAt: new Date().toISOString(),
460+
},
461+
},
462+
};
463+
};
464+
465+
export const Optimistic = {
466+
updateMetricAlertRule: updateMetricAlertRuleOptimistic,
467+
};

packages/web/app/src/lib/urql.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import schema from '@/gql/schema';
66
import { authExchange } from '@urql/exchange-auth';
77
import { cacheExchange } from '@urql/exchange-graphcache';
88
import { relayPagination } from '@urql/exchange-graphcache/extras';
9-
import { Mutation } from './urql-cache';
9+
import { Mutation, Optimistic } from './urql-cache';
1010
import { networkStatusExchange } from './urql-exchanges/state';
1111

1212
const noKey = (): null => null;
@@ -32,6 +32,7 @@ export const urqlClient = createClient({
3232
updates: {
3333
Mutation,
3434
},
35+
optimistic: Optimistic,
3536
resolvers: {
3637
Target: {
3738
appDeployments: relayPagination(),

packages/web/app/src/pages/target-alerts-detail.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ export function TargetAlertsDetailPage(props: {
271271
/>
272272
</div>
273273

274-
<aside className="w-87 sticky top-6 shrink-0 self-start">
274+
<aside className="w-94 sticky top-6 shrink-0 self-start">
275275
<AlertConditionsPanel
276276
rule={rule}
277277
organizationSlug={organizationSlug}

packages/web/app/src/pages/target-alerts-rules.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ArrowDown, Info } from 'lucide-react';
44
import { useQuery } from 'urql';
55
import { DataTable } from '@/components/base/data-table/data-table';
66
import { PageLead } from '@/components/base/page-lead';
7-
import { BadgeRounded } from '@/components/ui/badge';
7+
import { Badge, BadgeRounded } from '@/components/ui/badge';
88
import { Spinner } from '@/components/ui/spinner';
99
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
1010
import { Avatar } from '@/components/v2/avatar';
@@ -156,7 +156,12 @@ const columnHelper = createColumnHelper<RuleRow>();
156156
const RULE_COLUMNS: ColumnDef<RuleRow, any>[] = [
157157
columnHelper.accessor('name', {
158158
header: ({ column }) => <SortableHeader column={column} label="Name" />,
159-
cell: info => <span className="text-neutral-12 font-medium">{info.getValue()}</span>,
159+
cell: info => (
160+
<span className="inline-flex items-center gap-2">
161+
<span className="text-neutral-12 font-medium">{info.getValue()}</span>
162+
{!info.row.original.enabled && <Badge variant="outline">Paused</Badge>}
163+
</span>
164+
),
160165
}),
161166
columnHelper.accessor('type', {
162167
header: 'Type',
@@ -198,7 +203,9 @@ const RULE_COLUMNS: ColumnDef<RuleRow, any>[] = [
198203
sortingFn: (a, b) =>
199204
new Date(a.original.updatedAt).getTime() - new Date(b.original.updatedAt).getTime(),
200205
cell: info => (
201-
<span className="text-neutral-11 font-mono text-xs">{relativeTime(info.getValue())}</span>
206+
<span className="text-neutral-11 inline-block min-w-[180px] whitespace-nowrap font-mono text-xs">
207+
{relativeTime(info.getValue())}
208+
</span>
202209
),
203210
}),
204211
columnHelper.display({
@@ -290,7 +297,8 @@ export function TargetAlertsRulesPage(props: {
290297
requestPolicy: 'cache-and-network',
291298
});
292299

293-
const data = useKeepPreviousData(result.data, result.fetching || result.stale);
300+
const previousData = useKeepPreviousData(result.data, result.fetching || result.stale);
301+
const data = result.data ?? previousData;
294302
const rules: RuleRow[] = useMemo(
295303
() =>
296304
(data?.target?.metricAlertRules ?? []).map(r => ({
@@ -332,7 +340,11 @@ export function TargetAlertsRulesPage(props: {
332340
}
333341
/>
334342

335-
{result.fetching && !data ? (
343+
{result.error && !data ? (
344+
<div className="flex justify-center py-12 text-sm text-red-500">
345+
Failed to load alert rules: {result.error.message}
346+
</div>
347+
) : result.fetching && !data ? (
336348
<div className="flex justify-center py-12">
337349
<Spinner />
338350
</div>

0 commit comments

Comments
 (0)