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
28 changes: 24 additions & 4 deletions SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,35 @@ test("scheduled job create, adapters, pause/resume, receive now, list, delete",

await expect(page).toHaveURL(/\/subscriptions\/\d+$/);
await expect(page.getByRole("heading", { name })).toBeVisible();
await expect(page.getByRole("button", { name: "Active" })).toBeVisible();
// A badge now, not a button — the switch that turns it off lives with Pause on the right.
await expect(page.getByText("Active", { exact: true }).first()).toBeVisible();

// Disable / enable. Distinct from pause below: this one drops the work rather than holding it,
// and unlike the chip it replaced it applies on confirm instead of waiting for a save.
await page.getByRole("button", { name: "Disable", exact: true }).click();
await page
.getByRole("dialog", { name: "Disable this subscription?" })
.getByRole("button", { name: "Disable" })
.click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByText("Disabled", { exact: true }).first()).toBeVisible();

await page.getByRole("button", { name: "Enable", exact: true }).click();
await page
.getByRole("dialog", { name: "Enable this subscription?" })
.getByRole("button", { name: "Enable" })
.click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByText("Disabled", { exact: true })).toHaveCount(0);

// Pause / resume.
await page.getByRole("button", { name: "Pause" }).click();
// Pause / resume. `exact` because the history card's "Show what changed: …, PausedOn, …"
// button is also a substring match for "Pause" once there are edits to show.
await page.getByRole("button", { name: "Pause", exact: true }).click();
await page.getByRole("dialog", { name: "Pause this subscription?" }).getByRole("button", { name: "Pause" }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByText("Paused", { exact: true }).first()).toBeVisible();

await page.getByRole("button", { name: "Resume" }).click();
await page.getByRole("button", { name: "Resume", exact: true }).click();
await page.getByRole("dialog", { name: "Resume this subscription?" }).getByRole("button", { name: "Resume" }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByText("Paused", { exact: true })).toHaveCount(0);
Expand Down
22 changes: 14 additions & 8 deletions SW.Bitween.Web/ClientApp/src/components/config/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,18 @@ export function WiredHealthBadge({
* Everything here comes from caches the app already holds, keyed by subscription
* id; the gateway endpoints know none of it.
*/
/**
* Work group and retry policy are the two columns that sit between "Runs" and
* "Status" and, rendered as plain links like it, gave the row three stretches of
* identical 13px text with nothing but padding between them — which is what made
* the table hard to read across. They are both *an assignment to a named thing*,
* so they take a shared chip: it groups the pair, and separates them from the
* subscription link on one side and the status badges on the other.
*/
const assignmentChip =
"inline-flex items-center rounded-md bg-ink-50 px-1.5 py-0.5 text-[12px] text-ink-600 " +
"hover:bg-ink-100 hover:text-crimson-700";

export function useWiredSubscriptionColumns<T>(
subscriptionIdOf: (row: T) => number,
/** Off where the parent already fixes it — a bus gateway listens for one type. */
Expand Down Expand Up @@ -726,10 +738,7 @@ export function useWiredSubscriptionColumns<T>(
return <span className="text-[13px] text-ink-400">Ungrouped</span>;
const name = workGroupNames.get(id);
return name ? (
<Link
to={`/work-groups/${id}`}
className="text-[13px] text-ink-700 hover:text-crimson-700 hover:underline"
>
<Link to={`/work-groups/${id}`} className={assignmentChip}>
{name}
</Link>
) : (
Expand All @@ -745,10 +754,7 @@ export function useWiredSubscriptionColumns<T>(
return <span className="text-[13px] text-ink-400">None</span>;
const name = retryPolicyNames.get(id);
return name ? (
<Link
to={`/retry-policies/${id}`}
className="text-[13px] text-ink-700 hover:text-crimson-700 hover:underline"
>
<Link to={`/retry-policies/${id}`} className={assignmentChip}>
{name}
</Link>
) : (
Expand Down
8 changes: 6 additions & 2 deletions SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,12 @@ export function MiniTable<T>({
const start = Math.min(page, Math.max(0, Math.ceil(matches.length / pageSize) - 1)) * pageSize;
const shown = paged ? matches.slice(start, start + pageSize) : matches;

// `px-2`, not the `px-1` this started at: shrink-to-content columns sit at
// exactly twice the padding from their neighbours, and at 2px a panel holding
// half a dozen of them read as one run-on row. Still tighter than the
// page-level table's `px-3`, which is the density a panel is for.
const cell = (c: Column<T>) =>
`px-1 ${widthClass(c, true)} ${c.align === "right" ? "text-right" : ""} ${c.className ?? ""}`;
`px-2 ${widthClass(c, true)} ${c.align === "right" ? "text-right" : ""} ${c.className ?? ""}`;

return (
<div className="space-y-2">
Expand All @@ -217,7 +221,7 @@ export function MiniTable<T>({
{matches.length === 0 ? (
<p className="py-1 text-sm text-ink-500">No {search?.noun ?? "rows"} match “{query.trim()}”.</p>
) : (
<div className="-mx-1 overflow-x-auto">
<div className="-mx-2 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-ink-100 text-[11px] font-medium tracking-wide text-ink-400 uppercase">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,12 @@ export function ApiGatewayPage() {
columns={[
{
header: "Partner",
// Both name columns wrap rather than shrink to fit. Every column here but
// "Last error" was shrink-to-content, so all the slack in a full-width panel
// pooled in that one column and left the other six bunched against each other
// down the left-hand side — which is what read as cramped, more than the
// padding did. `wrap` is what the width strategy says a name should use anyway.
wrap: true,
cell: (a) => (
<Link
to={`/partners/${a.partnerId}`}
Expand All @@ -230,6 +236,7 @@ export function ApiGatewayPage() {
},
{
header: "Runs",
wrap: true,
cell: (a) => (
<Link
to={`/subscriptions/${a.subscriptionId}`}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { DownloadCloud, FileStack, Pause, Play, Trash2, X } from "lucide-react";
import { DownloadCloud, FileStack, Pause, Play, Power, PowerOff, Trash2, X } from "lucide-react";
import { api } from "../../api";
import { Can, useSessionCan } from "../../auth/guards";
import { Badge, Button, EmptyState, FormError, LoadingBlock } from "../../components/ui/basics";
import { Button, EmptyState, FormError, LoadingBlock } from "../../components/ui/basics";
import { ConfirmDialog, dialogsOpen } from "../../components/ui/overlays";
import { CodeBadge, EditableTitle, Panel, UnsavedBar } from "../../components/ui/Panel";
import { AdapterConfig, useAdapterCatalog } from "../../components/config/AdapterConfig";
import { MatchExpressionEditor } from "../../components/config/MatchExpressionEditor";
import { ScheduleEditor } from "../../components/config/ScheduleEditor";
import { AggregationFields } from "../../components/config/AggregationFields";
import { TypeBadge, scheduleFault, useSubscriptionsCache } from "../../components/config/shared";
import {
SubscriptionStatusBadges,
TypeBadge,
scheduleFault,
useSubscriptionsCache,
} from "../../components/config/shared";
import { STAGES, stagesFor, type StageId } from "./studio/stages";
import { DataSourceBinding } from "./studio/DataSourceBinding";
import { useBindsToDataSource } from "../data-sources/providers";
Expand Down Expand Up @@ -68,6 +73,10 @@ export function SubscriptionPage() {
const [draft, setDraft] = useState<Draft | null>(null);
const [deleting, setDeleting] = useState(false);
const [confirmingPause, setConfirmingPause] = useState(false);
// The state being asked for, not a flag: the dialog then confirms and writes the
// action the operator actually opened it for, rather than re-deriving it from a
// `s.enabled` that a refetch could have flipped underneath the open dialog.
const [enabling, setEnabling] = useState<boolean | null>(null);
const [confirmingReceive, setConfirmingReceive] = useState(false);
const [confirmingAggregate, setConfirmingAggregate] = useState(false);
const [loaded, setLoaded] = useState(false);
Expand Down Expand Up @@ -133,6 +142,19 @@ export function SubscriptionPage() {
onSuccess: invalidate,
});

// Its own one-field write rather than a draft edit ridden along on the save
// bar: this is the switch that drops work on the floor, so it applies when
// asked for and not at some later save of unrelated configuration. The draft
// is patched to match so an operator's half-finished edits below survive it —
// and so the value doesn't immediately read back as unsaved.
const setEnabled = useMutation({
mutationFn: (enabled: boolean) => api.updateSubscription(subscriptionId, { enabled }),
onSuccess: async (updated) => {
await invalidate();
setDraft((d) => (d ? { ...d, enabled: updated.enabled } : d));
},
});

const aggregate = useMutation({
mutationFn: () => api.aggregateNow(subscriptionId),
onSuccess: async () => {
Expand Down Expand Up @@ -402,20 +424,12 @@ export function SubscriptionPage() {
placeholder="Subscription name"
/>
<TypeBadge type={s.type} />
{draft && (
<button
type="button"
disabled={!canEdit}
onClick={() => set("enabled", !draft.enabled)}
title="Disabled subscriptions are never scheduled or matched."
className={`shrink-0 rounded-md px-2 py-0.5 text-xs font-medium disabled:cursor-not-allowed ${
draft.enabled ? "bg-ok-100 text-ok-600 hover:bg-ok-200/70" : "bg-ink-100 text-ink-700 hover:bg-ink-200"
}`}
>
{draft.enabled ? "Active" : "Disabled"}
</button>
)}
{paused && <Badge tone="warn">Paused</Badge>}
{/* Status is reported here and changed from the buttons on the right.
It used to be a clickable chip, indistinguishable from the Paused
badge beside it, that only edited the draft — so the lossy switch
saved later and quietly, while Pause, which keeps the work, asked
first and applied at once. */}
<SubscriptionStatusBadges enabled={s.enabled} paused={paused} />
</h1>
<p className="mt-1 text-sm text-ink-500">
Carries{" "}
Expand Down Expand Up @@ -473,6 +487,22 @@ export function SubscriptionPage() {
{paused ? "Resume" : "Pause"}
</Button>
)}
{/* Beside Pause, because the two questions an operator actually has
are the same shape — stop it, or hold it — and the answer differs
only in whether the work survives. */}
{canEdit && (
<Button
onClick={() => setEnabling(!s.enabled)}
title={
s.enabled
? "Stop it running at all. Work that arrives while it is off is not kept — pause instead to hold it."
: "Let it match and run again."
}
>
{s.enabled ? <PowerOff className="size-4" /> : <Power className="size-4" />}
{s.enabled ? "Disable" : "Enable"}
</Button>
)}
<Can permission="subscriptions.delete">
<Button variant="danger" onClick={() => setDeleting(true)}>
<Trash2 className="size-4" /> Delete
Expand Down Expand Up @@ -538,6 +568,29 @@ export function SubscriptionPage() {
/>
)}

{enabling !== null && (
<ConfirmDialog
title={enabling ? "Enable this subscription?" : "Disable this subscription?"}
body={
enabling ? (
`${s.name} starts being matched and scheduled again.`
) : (
<>
<strong className="font-medium text-ink-800">{s.name}</strong> stops being matched
and stops being scheduled. Anything that arrives for it while it is off is{" "}
<strong className="font-medium text-ink-800">not kept</strong> — pause it instead to
hold that work and release it later.
</>
)
}
confirmLabel={enabling ? "Enable" : "Disable"}
onConfirm={async () => {
await setEnabled.mutateAsync(enabling);
}}
onClose={() => setEnabling(null)}
/>
)}

{confirmingReceive && (
<ConfirmDialog
title="Receive now?"
Expand Down
Loading