hive: remove the read-only schedules page and the old schedule store
Scheduled prompts live at swarm level (swarm-controller's /api/schedules, `swarmctl schedule`, the swarm UI's /schedules page). The hive dashboard's SCH3DUL3S tab only listed the rows left in hive-c0re's `scheduled_prompts` table so operators could copy them over; the operator confirmed everything is migrated. Removed: - the SCH3DUL3S tab, its pane, schedules.js, its CSS (including the `.rqe-source*` rules, whose only emitter was schedules.js) and the hub tile's "schedules" entry - hive-c0re's GET /api/schedules and the `scheduled_prompts` store (with its v1 column migration and test) - hive-sh4re's `schedule` wire types, which only that route used - docs describing the tab, the route and the two tables The rebuild-queue cancel route that shared the module moves to dashboard/rebuild_queue.rs under an OpenAPI tag of its own. The two sqlite tables are not dropped: nothing opens them any more, and persistence.md says they can be dropped by hand. Closes #4916
This commit is contained in:
parent
2f05e2db28
commit
78412b3d3b
22 changed files with 84 additions and 979 deletions
|
|
@ -4,8 +4,8 @@
|
|||
|
||||
/* SYST3M panel rules (rebuild queue / meta inputs / container load)
|
||||
live in system-sections.css, shared with /core.html. A few are still
|
||||
referenced from dashboard JS (the schedules view uses `.rqe-source*`),
|
||||
so the dashboard pulls them in here. */
|
||||
referenced from the dashboard (`.rebuild-queue`), so the dashboard
|
||||
pulls them in here. */
|
||||
@import "./system-sections.css";
|
||||
@import "@hive/shared/jobq-rollup.css";
|
||||
|
||||
|
|
@ -709,113 +709,6 @@ footer .banner-thin {
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── scheduled prompts tab ────────────────────────────────────────
|
||||
Read-only table of the schedules left in the hive's store. */
|
||||
|
||||
.schedules-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.schedules-table {
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82em;
|
||||
}
|
||||
.schedules-table th,
|
||||
.schedules-table td {
|
||||
padding: 0.35em 0.6em;
|
||||
border: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.schedules-table thead th {
|
||||
background: var(--bg-elev);
|
||||
color: var(--muted);
|
||||
font-weight: normal;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.schedules-table-id {
|
||||
width: 3em;
|
||||
text-align: center;
|
||||
}
|
||||
.schedules-table-next-col {
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* every column: sized to typical content (short durations). */
|
||||
.schedules-table-every-col {
|
||||
width: 5em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.schedules-table-body-th {
|
||||
min-width: 12em;
|
||||
}
|
||||
/* Agent-name columns: 90° vertical text (writing-mode) so names are fully
|
||||
readable without truncation. Previously used a -45° CSS transform which
|
||||
clipped names mid-glyph. writing-mode + rotate(180deg) is in-flow and
|
||||
needs no absolute positioning or overflow hacks. */
|
||||
.schedules-table-agent-th {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 8em;
|
||||
padding: 4px 2px !important;
|
||||
vertical-align: bottom;
|
||||
text-transform: none !important;
|
||||
letter-spacing: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
.schedules-table-agent-th > div {
|
||||
font-family: ui-monospace, "JetBrains Mono", monospace;
|
||||
font-size: 0.85em;
|
||||
color: var(--fg);
|
||||
/* Rotate 90° bottom-to-top (conventional rotated column header). */
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-height: 7.5em;
|
||||
}
|
||||
.schedules-table-agent-th > div > span {
|
||||
display: inline-block;
|
||||
padding: 0 2px;
|
||||
}
|
||||
/* Column for an agent no longer in the live roster — its past schedules
|
||||
linger but the agent is gone. Greyed + struck through. */
|
||||
.schedules-table-agent-th--gone > div {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.schedules-table-row-cancelled td {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.schedules-table-row-paused td {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.sched-paused-label {
|
||||
color: var(--yellow);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.schedules-table-body-cell {
|
||||
max-width: 30em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, "JetBrains Mono", monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.schedules-table-check {
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
color: var(--green);
|
||||
padding: 0.3em 0 !important;
|
||||
}
|
||||
.schedules-table-check-cancelled {
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Selection bar — sticky-bottom strip for bulk actions. */
|
||||
.selection-bar {
|
||||
position: fixed;
|
||||
|
|
|
|||
|
|
@ -53,21 +53,6 @@
|
|||
<span class="tab-label">◆ P3RM1SS10NS ◆</span>
|
||||
</a>
|
||||
|
||||
<!-- SCH3DUL3S: read-only scheduled-prompts list. Count pill
|
||||
mirrors the active (non-cancelled) schedule count; hidden
|
||||
when zero. -->
|
||||
<a
|
||||
class="tab"
|
||||
id="tab-schedules"
|
||||
href="#schedules"
|
||||
role="tab"
|
||||
aria-controls="tab-pane-schedules"
|
||||
data-tab="schedules"
|
||||
>
|
||||
<span class="tab-label">◆ SCH3DUL3S ◆</span>
|
||||
<span class="tab-count" id="tab-count-schedules" hidden></span>
|
||||
</a>
|
||||
|
||||
<!-- ST4TS lives on its own page now (`/stats.html`), reached from
|
||||
the H0M3 hub — the hive-wide rollup is a standalone read-only
|
||||
view, not part of the operational tab strip. -->
|
||||
|
|
@ -190,38 +175,6 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SCH3DUL3S: read-only list of the scheduled prompts left in the
|
||||
hive's store, driven by GET /api/schedules (re-fetched on tab
|
||||
activation). Nothing on the hive fires them; the banner points
|
||||
at `swarmctl schedule`, where schedules live now.
|
||||
See docs/web-ui/dashboard.md::SCH3DUL3S tab. -->
|
||||
<section
|
||||
class="tab-pane"
|
||||
id="tab-pane-schedules"
|
||||
data-tab-pane="schedules"
|
||||
hidden
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-schedules"
|
||||
>
|
||||
<h2>◆ SCH3DUL3S ◆</h2>
|
||||
<div class="divider">
|
||||
══════════════════════════════════════════════════════════════
|
||||
</div>
|
||||
<p class="server-warn server-warn-warn" role="note">
|
||||
⚠ this page is slated for removal. the hive no longer fires these
|
||||
schedules — they live at swarm level now: recreate each one with
|
||||
<code>swarmctl schedule add</code> (see
|
||||
<code>docs/swarm/schedules.md</code>).
|
||||
</p>
|
||||
<p class="meta">
|
||||
read-only: every schedule still in the hive's table, kept so it can
|
||||
be copied over.
|
||||
</p>
|
||||
<div id="schedules-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
|
||||
(`/stats.html`), reached from the H0M3 hub. The markup + the
|
||||
hive-stats render JS moved there; when tabs.js boots on the
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@
|
|||
<span class="home-tile-label">Dashboard</span>
|
||||
</span>
|
||||
<span class="home-tile-desc"
|
||||
>containers · permissions · schedules · system</span
|
||||
>containers · permissions · system</span
|
||||
>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,232 +0,0 @@
|
|||
// Dashboard SCH3DUL3S tab — read-only list of the scheduled prompts left
|
||||
// in the hive's store, so the operator can recreate them with
|
||||
// `swarmctl schedule`. Nothing on the hive fires or edits them; the list
|
||||
// is fetched from `/api/schedules` on tab activation and state loads.
|
||||
//
|
||||
// Owns its own module state (the schedules list); reads the shared agent
|
||||
// roster from state.js for the per-agent columns. Render + format helpers
|
||||
// come from util.js.
|
||||
//
|
||||
// Note: per-agent reminders live in the in-container store, not here —
|
||||
// they surface via get_loose_ends / the todos pill on the agent page.
|
||||
|
||||
import { $ } from "./common.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import { paintAtomic, epochSec, fmtAgo, fmtDuration } from "./util.js";
|
||||
import { containersState } from "./state.js";
|
||||
|
||||
// Local cache lets `refreshTabCounts` show the active count without
|
||||
// re-fetching every second.
|
||||
let schedulesState = [];
|
||||
export async function refreshSchedules() {
|
||||
const listRoot = $("schedules-section");
|
||||
if (!listRoot) return;
|
||||
try {
|
||||
const resp = await fetch("/api/schedules");
|
||||
if (!resp.ok) {
|
||||
paintAtomic(listRoot, (root) => {
|
||||
root.append(
|
||||
el(
|
||||
"p",
|
||||
{ class: "empty" },
|
||||
"schedules unavailable: http " + resp.status,
|
||||
),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
schedulesState = await resp.json();
|
||||
} catch (err) {
|
||||
paintAtomic(listRoot, (root) => {
|
||||
root.append(
|
||||
el("p", { class: "empty" }, "schedules fetch failed: " + err),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
renderSchedulesList();
|
||||
}
|
||||
// Active = at least one target still alive (no `cancelled_at_unix`)
|
||||
// AND the whole schedule isn't cancelled. Drives the tab pill count.
|
||||
export function activeScheduleCount() {
|
||||
let n = 0;
|
||||
for (const s of schedulesState) {
|
||||
if (s.cancelled_at_unix) continue;
|
||||
if ((s.targets || []).some((t) => !t.cancelled_at_unix)) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
// Table layout: one row per schedule, attribute columns + one ✓/✕
|
||||
// column per agent (vertical header so a row of agents takes ~28px
|
||||
// each instead of full word width).
|
||||
function renderSchedulesList() {
|
||||
const liveRoot = $("schedules-section");
|
||||
if (!liveRoot) return;
|
||||
paintAtomic(liveRoot, (root) => {
|
||||
if (!schedulesState.length) {
|
||||
root.append(el("p", { class: "empty" }, "no schedules left"));
|
||||
return;
|
||||
}
|
||||
const agents = schedulesTableAgentSet();
|
||||
const table = el("table", { class: "schedules-table" });
|
||||
table.append(renderSchedulesTableHead(agents));
|
||||
const tbody = el("tbody", {});
|
||||
const sorted = schedulesState.slice().sort((a, b) => {
|
||||
// Cancelled → bucket 2, paused → bucket 1, active → bucket 0.
|
||||
const aOrd = a.cancelled_at_unix ? 2 : a.paused_at_unix ? 1 : 0;
|
||||
const bOrd = b.cancelled_at_unix ? 2 : b.paused_at_unix ? 1 : 0;
|
||||
if (aOrd !== bOrd) return aOrd - bOrd;
|
||||
return epochSec(a.next_fire_at_unix) - epochSec(b.next_fire_at_unix);
|
||||
});
|
||||
for (const s of sorted) tbody.append(renderScheduleRow(s, agents));
|
||||
table.append(tbody);
|
||||
const wrap = el("div", { class: "schedules-table-wrap" });
|
||||
wrap.append(table);
|
||||
root.append(wrap);
|
||||
});
|
||||
}
|
||||
// The set of agent columns in the schedules table: live containers
|
||||
// (sorted), then any extra names that appear as a schedule target but
|
||||
// aren't in the live container list.
|
||||
function schedulesTableAgentSet() {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
const push = (n) => {
|
||||
if (!seen.has(n)) {
|
||||
seen.add(n);
|
||||
out.push(n);
|
||||
}
|
||||
};
|
||||
const containerNames = Array.from(containersState.values())
|
||||
.map((c) => c.name)
|
||||
.filter((n) => n !== "operator")
|
||||
.sort();
|
||||
for (const n of containerNames) push(n);
|
||||
for (const s of schedulesState) {
|
||||
for (const t of s.targets || []) push(t.target);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function renderSchedulesTableHead(agents) {
|
||||
const thead = el("thead", {});
|
||||
const headerRow = el("tr", {});
|
||||
headerRow.append(
|
||||
el("th", { class: "schedules-table-id" }, "#"),
|
||||
el("th", {}, "src"),
|
||||
el("th", { class: "schedules-table-next-col" }, "next"),
|
||||
el("th", { class: "schedules-table-every-col" }, "every"),
|
||||
el("th", {}, "owner"),
|
||||
el("th", { class: "schedules-table-body-th" }, "body"),
|
||||
);
|
||||
// Every column maps to a container, so flag any column whose agent is
|
||||
// no longer in the live roster — its past schedules linger in the
|
||||
// table but the agent is gone.
|
||||
const liveNames = new Set(
|
||||
Array.from(containersState.values()).map((c) => c.name),
|
||||
);
|
||||
for (const a of agents) {
|
||||
const gone = !liveNames.has(a);
|
||||
headerRow.append(
|
||||
el(
|
||||
"th",
|
||||
{
|
||||
class:
|
||||
"schedules-table-agent-th" +
|
||||
(gone ? " schedules-table-agent-th--gone" : ""),
|
||||
title: gone ? a + " — no longer exists (gone)" : a,
|
||||
},
|
||||
el("div", {}, el("span", {}, a)),
|
||||
),
|
||||
);
|
||||
}
|
||||
thead.append(headerRow);
|
||||
return thead;
|
||||
}
|
||||
function renderScheduleRow(s, agents) {
|
||||
const cancelled = !!s.cancelled_at_unix;
|
||||
const paused = !cancelled && !!s.paused_at_unix;
|
||||
const tr = el("tr", {
|
||||
class:
|
||||
"schedules-table-row" +
|
||||
(cancelled ? " schedules-table-row-cancelled" : "") +
|
||||
(paused ? " schedules-table-row-paused" : ""),
|
||||
});
|
||||
|
||||
tr.append(el("td", { class: "meta schedules-table-id" }, "#" + s.id));
|
||||
|
||||
const srcKind =
|
||||
s.source && s.source.kind === "approval" ? "approval" : "manual";
|
||||
tr.append(
|
||||
el(
|
||||
"td",
|
||||
{},
|
||||
el(
|
||||
"span",
|
||||
{ class: "rqe-source rqe-source-" + srcKind },
|
||||
srcKind === "approval" ? "approval" : "operator",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// "next" cell — the stored next fire time, absolute: it is the value
|
||||
// to pass as `swarmctl schedule add --at`. Cancelled / paused rows say
|
||||
// so, with the time in the title.
|
||||
const nextIso = new Date(s.next_fire_at_unix).toISOString();
|
||||
const nextCell = el("td", { class: "meta schedules-table-next-col" });
|
||||
if (cancelled) {
|
||||
nextCell.title = "cancelled " + new Date(s.cancelled_at_unix).toISOString();
|
||||
nextCell.textContent = "cancelled";
|
||||
} else if (paused) {
|
||||
nextCell.title =
|
||||
"paused since " +
|
||||
new Date(s.paused_at_unix).toISOString() +
|
||||
"\nnext fire " +
|
||||
nextIso;
|
||||
nextCell.append(el("span", { class: "sched-paused-label" }, "⏸ paused"));
|
||||
} else {
|
||||
nextCell.textContent = nextIso;
|
||||
}
|
||||
tr.append(nextCell);
|
||||
|
||||
tr.append(
|
||||
el(
|
||||
"td",
|
||||
{ class: "meta schedules-table-every-col" },
|
||||
s.interval_seconds ? "↻ " + fmtDuration(s.interval_seconds) : "one-shot",
|
||||
),
|
||||
);
|
||||
|
||||
tr.append(el("td", { class: "meta" }, s.owner));
|
||||
|
||||
// Body cell — truncates with ellipsis; full body + description on hover.
|
||||
const bodyCell = el("td", { class: "schedules-table-body-cell" });
|
||||
const bodyText = s.body || "";
|
||||
bodyCell.title = (s.description ? s.description + "\n\n" : "") + bodyText;
|
||||
bodyCell.textContent = bodyText;
|
||||
tr.append(bodyCell);
|
||||
|
||||
// Per-agent target cells: ✓ active target, muted ✕ cancelled target,
|
||||
// empty when the agent isn't a target. Last fire + result on hover.
|
||||
const targetByName = new Map();
|
||||
for (const t of s.targets || []) targetByName.set(t.target, t);
|
||||
for (const a of agents) {
|
||||
const t = targetByName.get(a);
|
||||
const td = el("td", { class: "schedules-table-check" });
|
||||
if (t) {
|
||||
td.title =
|
||||
(t.cancelled_at_unix ? "cancelled — " : "") +
|
||||
(t.last_fired_at_unix
|
||||
? "last fired " + fmtAgo(t.last_fired_at_unix) + " ago"
|
||||
: "never fired") +
|
||||
(t.last_result ? " · " + t.last_result : "");
|
||||
td.append(
|
||||
t.cancelled_at_unix
|
||||
? el("span", { class: "schedules-table-check-cancelled" }, "✕")
|
||||
: el("span", {}, "✓"),
|
||||
);
|
||||
}
|
||||
tr.append(td);
|
||||
}
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// dashboard SPA: the live agent roster.
|
||||
//
|
||||
// The roster is read by nearly every tab (the container tree, the
|
||||
// capabilities + tool-group matrices, the schedule target-chips, the
|
||||
// capabilities + tool-group matrices, the
|
||||
// container-load poll, and the selection bar), so
|
||||
// it lives here as a single source of truth that each domain module
|
||||
// imports rather than threading through call signatures. Everything
|
||||
|
|
|
|||
|
|
@ -122,34 +122,6 @@
|
|||
color: var(--amber);
|
||||
font-weight: bold;
|
||||
}
|
||||
.rqe-source {
|
||||
font-size: 0.75em;
|
||||
padding: 0.05em 0.45em;
|
||||
border-radius: 0.7em;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.rqe-source-manual {
|
||||
color: var(--cyan);
|
||||
border-color: var(--cyan);
|
||||
}
|
||||
.rqe-source-meta_update {
|
||||
color: var(--purple);
|
||||
border-color: var(--purple);
|
||||
}
|
||||
.rqe-source-auto_update {
|
||||
color: var(--muted);
|
||||
}
|
||||
.rqe-source-crash_recover {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
}
|
||||
.rqe-source-approval {
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
/* ─── running-rebuild live log (renderRebuildLiveLog in builds.js) ──────────
|
||||
Streams the currently-running rebuild's build log inline under the queue.
|
||||
Hidden (native `hidden` attr) when nothing is building. Used on
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import {
|
|||
fetchAndRenderToolGroups,
|
||||
initPermissions,
|
||||
} from "./permissions.js";
|
||||
import { refreshSchedules, activeScheduleCount } from "./schedules.js";
|
||||
import {
|
||||
initJobqRollup,
|
||||
syncTransientsFromSnapshot,
|
||||
|
|
@ -56,7 +55,6 @@ window.marked = marked;
|
|||
const MANAGED_SECTION_IDS = [
|
||||
"containers-section",
|
||||
"inbox-section",
|
||||
"schedules-section",
|
||||
"capabilities-section",
|
||||
"tool-groups-section",
|
||||
];
|
||||
|
|
@ -149,7 +147,6 @@ window.marked = marked;
|
|||
// mount's own effect handles the actual fetch.
|
||||
initJobqRollup();
|
||||
renderContainers(s);
|
||||
refreshSchedules();
|
||||
restoreOpenDetails(openDetails);
|
||||
// No periodic refresh timer. Phase 6 covers every container
|
||||
// mutation with `ContainerStateChanged` / `ContainerRemoved`
|
||||
|
|
@ -248,8 +245,8 @@ window.marked = marked;
|
|||
})();
|
||||
|
||||
// ─── tab routing ───────────────────────────────────────────────────────
|
||||
// Hash-based: `#swarm` / `#system` / `#permissions` /
|
||||
// `#schedules` activate the matching pane on the dashboard. Empty
|
||||
// Hash-based: `#swarm` / `#system` / `#permissions` activate the
|
||||
// matching pane on the dashboard. Empty
|
||||
// hash defaults to SW4RM. FL0W and ST4TS are NOT tabs —
|
||||
// they're separate pages (`/flow.html`, `/stats.html`) reached from
|
||||
// the H0M3 hub. Tab routing only
|
||||
|
|
@ -266,11 +263,6 @@ window.marked = marked;
|
|||
// visibility (bar only belongs on SW4RM where agent cards live).
|
||||
document.body.dataset.activeTab = target;
|
||||
renderSelectionBar(Array.from(containersState.values()));
|
||||
// Re-fetch on activation as a safety net: SSE covers live mutations,
|
||||
// re-sync covers disconnect windows.
|
||||
if (target === "schedules") {
|
||||
refreshSchedules();
|
||||
}
|
||||
// Permissions tables: SSE covers worker-applied changes
|
||||
// (capabilities_changed / tool_groups_changed); re-fetch on
|
||||
// activation as a safety net for any gap between SSE events and
|
||||
|
|
@ -310,9 +302,6 @@ window.marked = marked;
|
|||
if (c.needs_update) swarm++;
|
||||
}
|
||||
setTabCount("swarm", swarm);
|
||||
// SCH3DUL3S — count of schedules with at least one still-active
|
||||
// target: the ones left to recreate with `swarmctl schedule`.
|
||||
setTabCount("schedules", activeScheduleCount());
|
||||
}
|
||||
// Poll the state stores on a 1s tick to keep the pill counts in
|
||||
// sync. The state stores are mutated synchronously by every SSE
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Shared dashboard render + format helpers.
|
||||
//
|
||||
// Small pure utilities used across most dashboard tabs (the container
|
||||
// tree, the rebuild queue, schedules + reminders):
|
||||
// tree, the rebuild queue):
|
||||
// an atomic-swap render helper and a handful of relative-time / duration
|
||||
// formatters. They live here — a dashboard-internal module — rather than
|
||||
// in the cross-page `common.js`, because they're specific to the
|
||||
|
|
|
|||
Loading…
Reference in a new issue