- #6459: card More menu rendered empty (no Delete): the vendored jade
  compiler's htmljs patch made isConstructedObject(Array) false, so
  'label {{_ ...}}:' lines got their content array mis-assigned as tag
  attributes and Blaze threw 'foreign objects in attributes' killing the
  whole cardMorePopup. Also fixed the list More link input (nonexistent
  rootUrl helper -> absoluteUrl).
- #6455: saving a card description never removed a pre-existing unsaved-
  edits record, and the draft/description comparison mismatched on Markdown
  hard-breaks and empty descriptions, re-creating a phantom draft on every
  save. Save now clears the draft; both sides normalized the same way.
- #5547: comment drafts: the draft-saving escape handler had been disarmed
  since 2019 (flag with no setter), so a comment being written was lost
  when the card closed. Drafts are now saved continuously (debounced),
  flushed on teardown, cleared on submit; text no longer visually vanishes
  on outside clicks.
- #5282: attachment delete: guard against the doc being already evicted
  from Minimongo ('Removed nonexistent document'), like the existing
  comment/checklist guards.
- #6472: rules move card to top/bottom: fall back to the card's current
  list when listName does not resolve (was a swallowed TypeError = rule
  silently did nothing); wizard stored listTitle which the engine never
  reads (listName); empty destination wrote sort +-Infinity; rules JSON/CSV
  import now uses the rules.createRule method instead of client inserts
  rejected into minimongo limbo, and defaults missing trigger matching
  fields to '*'; empty boardId no longer overrides the real board; the
  server-side orphaned-cards fallback in List.cards()/cardsUnfiltered()
  now actually resolves its async swimlane lookup.
- #6465: desktop density back to WeKan 6.09: base font 14px and headings
  22/18/16px (vw clamps resolved to 18/24px on desktop), All Boards
  wrapper padding 10px/15px, card details docks to the right edge instead
  of floating on top of its own card (still draggable), 20px canvas
  padding instead of ~48px white borders, removed the force-all-card-text-
  to-title-size rule, admin org/team table Select/Unselect-all header
  links are compact icons and header cells may wrap, zoom pill no longer
  cut off, one-click board settings cog in the board header.
- #6458 #6466: snap: mongodb-control now checks mongod's exit status and
  does an AVX pre-flight (MongoDB 5+ needs AVX; without it mongod SIGILLs)
  instead of pinging a dead port forever behind a 502: it switches to a
  completed FerretDB migration, or (re)runs the migration (AVX-free) with
  a 3-attempt guard. Migration importers no longer fail the whole run on
  >=10 per-item errors ('Avatars Errors' etc.), and a failed 3.x-source
  migration keeps the copied FerretDB data + checkpoint and resumes next
  start instead of deleting it and 'falling back' to a mongod 7 that
  cannot open 3.x data files.
- #6467 #6468: with FerretDB, calm Meteor's no-oplog polling storm
  (METEOR_POLLING_THROTTLE_MS=2000, METEOR_POLLING_INTERVAL_MS=30000
  defaults in the snap and bundle launcher; overridable). The FerretDB
  fork gets filter pushdown, a sane pool size and less locking in its own
  repo.
- Updated ChangeLog.

Thanks to xet7.
This commit is contained in:
Lauri Ojansivu committed 2026-07-16 17:08:31 +03:00
1 parent 1193f3a4b5
commit ed19445d30
24 files changed
+634 -120

No files matched your search

+125
View File
@@ -104,6 +104,131 @@ This release adds the following updates:
and fixes the following bugs:
- **GUI: the "More" menu of cards was completely empty, so cards could not be deleted from it**
([#6459](https://github.com/wekan/wekan/issues/6459),
`npm-packages/meteor-jade-loader/lib/vendor/htmljs.js`, `client/components/lists/listHeader.jade`).
A WeKan-local vm-sandbox patch in the vendored jade compiler's htmljs made
`isConstructedObject(Array)` return `false` (upstream returns `true`), so a tag whose inline text
compiles to an ARRAY — the `label {{_ 'source-board'}}:` and `label {{_ 'parent-card'}}:` lines in
cardMorePopup — got its content array mis-assigned as the tag's *attributes*. At runtime Blaze then
found a template view object inside the attributes and threw *"The basic TransformingVisitor does
not support foreign objects in attributes"* on every render, killing the whole popup — including
the card Delete link. The tag constructor now treats an array first-argument as content, like
upstream htmljs. Also fixed the list "More" popup's copy-link input, which was always empty because
it referenced a `rootUrl` helper that does not exist (now `absoluteUrl`). Board and swimlane menus
intentionally offer archive (delete lives in Sidebar → Archive), so only the card menu was actually
broken. Thanks to thku and xet7.
- **Cards: the "You have an unsaved description" warning could never be cleared by saving**
([#6455](https://github.com/wekan/wekan/issues/6455), reincarnation of
[#1287](https://github.com/wekan/wekan/issues/1287), `client/components/cards/cardDetails.js`,
`client/components/cards/cardDescription.js`). Two bugs: closing the description editor only
*avoided adding* a draft when the text matched the saved description — it never *removed* a
pre-existing draft record, so once the warning appeared, "View it" → Save could not clear it, only
Discard could. And the comparison matched a per-line-whitespace-stripped draft against the raw
stored description (or `null` when empty), so descriptions with Markdown `" "` hard-breaks
re-created a phantom draft on every save. Saving now removes the draft record explicitly, and both
sides of the comparison are normalized the same way. Thanks to C0rn3j and xet7.
- **Comments: a comment being written was lost when the card was closed or a click landed outside
the card** ([#5547](https://github.com/wekan/wekan/issues/5547),
`client/components/activities/comments.js`). The comment-draft machinery existed (the form even
prefills from it) but had been disarmed since 2019: the escape handler that saved the draft was
gated on a "form is open" flag that nothing ever set — the setter was removed back then because the
handler also *cleared the visible text* on every outside click. Now the draft is saved continuously
(debounced) while typing and flushed when the form is torn down, submit removes the draft, and the
escape handler no longer clears the visible text — so an unfinished comment survives closing the
card, and reopening the card restores it into the form. Thanks to Finnlife, webenefits and xet7.
- **Attachments: deleting an attachment could log a client-side "Removed nonexistent document"
exception even though the delete succeeded**
([#5282](https://github.com/wekan/wekan/issues/5282),
`client/components/cards/attachments.js`). Same class as the fixed #3252 for comments and
checklists: under publication churn the attachment document can already be evicted from Minimongo
when the confirm handler runs, and removing a missing _id throws. The delete now only runs when
the document is still in the local cache — the comment/checklist guards' missing sibling. Thanks
to lupuszr and xet7.
- **Rules: the "move card to top/bottom" actions did nothing**
([#6472](https://github.com/wekan/wekan/issues/6472), `server/rulesHelper.js`,
`client/components/rules/actions/boardActions.js`, `client/components/rules/rulesImportExport.js`,
`server/rulesButton.js`, `models/lists.js`). A pile-up of five bugs, all silent because the
activity hook swallows rule-action errors: (1) an unresolved destination list (typo'd, renamed,
case-mismatched, or on another board) crashed on `list.cardsUnfiltered` — now it falls back to the
card's current list; (2) the classic rule wizard's generic "move to top/bottom" stored the field as
`listTitle`, which the rule engine never reads (`listName`) — so every such rule created from the
wizard has never worked; (3) an empty destination list made `Math.min()/Math.max()` of nothing
write a corrupt `sort: ±Infinity`; (4) the rules JSON/CSV import created rules with raw client
inserts that the board-admin-only allow rules reject into minimongo limbo — it now uses the same
`rules.createRule` server method as the wizard, and defaults missing trigger matching fields (e.g.
`userId`) to the `*` wildcard so hand-written JSON matches; (5) `rules.createRule` let an empty
`boardId: ''` from a not-yet-loaded board selector override the real board. Also fixed the
server-side orphaned-cards fallback in `List.cards()/cardsUnfiltered()`, which silently never
applied because an async lookup was read synchronously. Thanks to jmb26240 and xet7.
- **GUI: desktop density regressions from the v7.98–v8.18 mobile UI work**
([#6465](https://github.com/wekan/wekan/issues/6465), `client/components/main/layouts.css`,
`client/components/cards/cardDetails.css`, `client/components/main/header.css`,
`client/components/settings/peopleBody.jade`, `client/components/settings/settingBody.css`,
`client/components/boards/boardHeader.jade`). Restores the WeKan 6.09 desktop look users asked
for: the base font is back to 14px (the `clamp(...2.5vw...)` introduced in v7.98 and raised in
v8.02 resolved to 18px on any window wider than 720px — +28.5% on every font, button and input,
the "everything is way too big" complaint) and headings back to 22/18/16px; the card details
window no longer opens as a huge floating sheet ON TOP of its own card — it docks to the right
edge like the classic side panel (still movable by its drag handle), with 6.09's 20px content
padding instead of ~48px white borders, a 3px corner radius, and without the v8.18 rule that
forced ALL card text to the title's size; the All Boards page header band is back to 6.09's
compact padding; the Admin Panel Organizations/Teams tables no longer explode column widths
("Select all / Unselect all" header links are now compact icons and header cells may wrap); the
zoom pill no longer renders cut off (fixed-pixel pill inside the 28px quick-access row); and
board settings opens with ONE click from a new cog button in the board header (the sidebar path
still works). Note: a missing watch "eye" icon is the Admin Panel → Features → Notifications
"disable watch" setting, not a regression. Thanks to Mintyt, csonkaoszimt, micha141076 and xet7.
- **Snap: WeKan served "502 Bad Gateway" forever when mongod could not start — including on CPUs
without AVX ("Illegal instruction")** ([#6458](https://github.com/wekan/wekan/issues/6458),
[#6466](https://github.com/wekan/wekan/issues/6466), `snap-src/bin/mongodb-control`,
`snap-src/bin/migration-control`). MongoDB 5.0+ x86_64 binaries require AVX; on CPUs without it
mongod dies instantly with SIGILL (exit 132). mongodb-control never checked the mongod fork's
exit status: it pinged the dead port for ~10 minutes, snapd restarted the service, and the cycle
repeated forever — same limbo as when the data files are still MongoDB 3.x ("This version of
MongoDB is too recent"). Now there is an AVX pre-flight and the fork/final-start exit codes are
checked: if a COMPLETED FerretDB migration exists the snap switches to it; otherwise the
MongoDB → FerretDB migration is (re)run — FerretDB is pure Go + SQLite and the 3.x reader uses
the bundled MongoDB 3.2 tools, so neither needs AVX — with a 3-attempt counter so a persistently
failing migration cannot ping-pong, and clear log guidance (`snap run wekan.migrate`).
migration-control also skips the pointless mongod 7 probe when AVX is missing. Thanks to kiarn
and xet7.
- **Snap: a migration that finished with a few per-item errors ("Avatars Errors") deleted the
fully-copied FerretDB database and left the snap serving 502 Bad Gateway**
([#6466](https://github.com/wekan/wekan/issues/6466), `snap-src/bin/migrate-mongo3-to-ferretdb.mjs`,
`releases/migrate-mongodb-to-ferretdb.mjs`, `snap-src/bin/migration-control`). Both importers
treated ≥10 logged errors of ANY kind as failure — but per-item errors (one document that fails
JSON parsing, one avatar that fails to extract) don't invalidate everything that DID copy. The
failure path then discarded the whole migrated SQLite, set `migrate=off`, and "fell back" to
MongoDB — impossible for a 6.09 upgrade, whose 3.x data files the bundled mongod 7 cannot open,
producing the reported endless `db-eval.mjs ping` loop and 502. Per-item errors are now logged
but non-fatal (only real failures — disk full, unreachable target/source — still fail), and a
failed 3.x-source migration keeps the partial FerretDB SQLite plus its checkpoint and RESUMES on
the next start instead of deleting hours of copied data. Thanks to Nissulya, S0QR2, lezioul,
usrflo and xet7.
- **Snap/Bundle: FerretDB v1 pinned 250–400% CPU and boards took minutes to load after migration**
([#6467](https://github.com/wekan/wekan/issues/6467),
[#6468](https://github.com/wekan/wekan/issues/6468), `snap-src/bin/wekan-control`,
`releases/ferretdb/start-wekan.sh`, and the wekan/FerretDB fork). Two sides. WeKan side: with
FerretDB there is no oplog, so Meteor observes every query by POLLING — and its defaults re-run
every observed query 50 ms after ANY write and at least every 10 s, which on an active board
multiplies into hundreds of full queries per second; with FerretDB the snap and the bundle
launcher now default to `METEOR_POLLING_THROTTLE_MS=2000` / `METEOR_POLLING_INTERVAL_MS=30000`
(overridable; own changes still appear instantly, other users' changes may take ~2 s longer).
FerretDB side (fork v1.28): real filter pushdown so `{boardId: X}` uses the SQLite expression
indexes instead of decoding the whole 53k-card collection per query, a connection pool cap of
2×CPUs (was 100 — dozens of concurrent full scans thrashing the pure-Go SQLite mutexes were the
reported 821k futex calls/30 s), and inserts no longer take the registry's global write lock.
Thanks to anlx-sw, markusst1982 and xet7.
- **LDAP: group search filters were double-escaped and broke group filtering**
([#6460](https://github.com/wekan/wekan/issues/6460),
[PR #6469](https://github.com/wekan/wekan/pull/6469), `packages/wekan-ldap/server/ldap.js`).
+79 -2
View File
@@ -10,7 +10,61 @@ const commentFormIsOpen = new ReactiveVar(false);
// Empty string means a new top-level comment.
const replyToCommentId = new ReactiveVar('');
// #5547: persist the comment draft (debounced) into UnsavedEdits while the user
// types, so a comment in progress survives closing the card, clicking outside
// it, or navigating away — the form prefills from {{getUnsavedValue
// 'cardComment' ...}} on the next open. Historical note: the click-open flag
// that armed the old escape-time draft save was removed in 2019 (3b3950369)
// because the escape handler CLEARED the visible text; since then nothing saved
// the draft at all, so any close of the card discarded the comment.
let commentDraftTimer = null;
let commentDraftPending = null; // { cardId, value }
function commentDraftWrite({ cardId, value }) {
if (!cardId) return;
const draftKey = { fieldName: 'cardComment', docId: cardId };
if ((value || '').trim()) {
UnsavedEdits.set(draftKey, value);
} else {
UnsavedEdits.reset(draftKey);
}
}
function scheduleCommentDraftSave(cardId, value) {
commentDraftPending = { cardId, value };
if (commentDraftTimer) clearTimeout(commentDraftTimer);
commentDraftTimer = setTimeout(() => {
commentDraftTimer = null;
const pending = commentDraftPending;
commentDraftPending = null;
if (pending) commentDraftWrite(pending);
}, 300);
}
// Run a pending (debounced) save immediately — e.g. the card is closing.
function flushCommentDraftSave() {
if (commentDraftTimer) {
clearTimeout(commentDraftTimer);
commentDraftTimer = null;
}
const pending = commentDraftPending;
commentDraftPending = null;
if (pending) commentDraftWrite(pending);
}
// Drop any pending save — the comment was submitted, so there is no draft.
function cancelCommentDraftSave() {
if (commentDraftTimer) {
clearTimeout(commentDraftTimer);
commentDraftTimer = null;
}
commentDraftPending = null;
}
Template.commentForm.onDestroyed(function () {
// #5547: the card can close without the escape handler ever running (the X
// button, a route change); make sure the last keystrokes reach the draft.
flushCommentDraftSave();
commentFormIsOpen.set(false);
$('.note-popover').hide();
});
@@ -28,6 +82,15 @@ Template.commentForm.helpers({
});
Template.commentForm.events({
// #5547: arm the escape handler below (it saves the draft and closes the
// form); this flag had no setter since 2019, so the handler never ran.
'focus .js-new-comment-input'() {
commentFormIsOpen.set(true);
},
// #5547: keep the draft continuously saved (debounced) while typing.
'input .js-new-comment-input'(evt) {
scheduleCommentDraftSave(Utils.getCurrentCardId(), evt.currentTarget.value);
},
'submit .js-new-comment-form'(evt, tpl) {
const input = tpl.$('.js-new-comment-input');
const text = input.val().trim();
@@ -48,6 +111,13 @@ Template.commentForm.events({
cardId,
parentId: parentId || '',
});
// #5547: the comment is saved — cancel any debounced draft save still in
// flight and remove the stored draft so it does not resurface next open.
cancelCommentDraftSave();
UnsavedEdits.reset({
fieldName: 'cardComment',
docId: Utils.getCurrentCardId(),
});
replyToCommentId.set('');
resetCommentInput(input);
Tracker.flush();
@@ -159,13 +229,20 @@ EscapeActions.register(
docId: Utils.getCurrentCardId(),
};
const commentInput = $('.js-new-comment-input');
const draft = commentInput.val().trim();
const draft = (commentInput.val() || '').trim();
cancelCommentDraftSave();
if (draft) {
UnsavedEdits.set(draftKey, draft);
} else {
UnsavedEdits.reset(draftKey);
}
resetCommentInput(commentInput);
// #5547: do NOT clear the input — clearing it on every click outside the
// comment box is exactly the "comment text disappearing" bug that got this
// handler disarmed in 2019 (3b3950369). The draft is saved above; if the
// card stays open, the text stays visible; if the card closes, the next
// open prefills the form from the draft.
commentInput.blur();
commentFormIsOpen.set(false);
},
() => {
return commentFormIsOpen.get();
@@ -159,6 +159,12 @@ template(name="boardHeaderBar")
i.fa.fa-times-thin
.separator
//- #6465: one-click board settings — the boardMenu popup used to need
//- two clicks (open sidebar, then Boardeinstellungen). The click
//- handler already exists in boardHeader.js ('click .js-open-board-menu').
if currentUser.isBoardMember
a.board-header-btn.js-open-board-menu(title="{{_ 'boardMenuPopup-title'}}")
i.fa.fa-cog
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
i.fa.fa-bars
+8 -1
View File
@@ -49,7 +49,14 @@ Template.attachmentGallery.events({
if (card && card.coverId === this._id) {
await card.unsetCover();
}
await Attachments.removeAsync(this._id);
// #5282 (same class as #3252 for comments/checklists): only remove if the
// doc is still in the local cache. Under publication churn the attachment
// can already be evicted from Minimongo, and removing a missing _id
// throws "Removed nonexistent document" even though the delete itself
// succeeded on the server.
if (this._id && ReactiveCache.getAttachment(this._id)) {
await Attachments.removeAsync(this._id);
}
Popup.back();
}),
});
@@ -1,3 +1,5 @@
import { UnsavedEdits } from '/client/lib/unsavedEdits';
const descriptionFormIsOpen = new ReactiveVar(false);
Template.descriptionForm.onDestroyed(function () {
@@ -16,6 +18,10 @@ Template.descriptionForm.events({
event.preventDefault();
const description = tpl.currentComponent ? tpl.currentComponent().getValue() : tpl.$('textarea').val();
await this.setDescription(description);
// #6455: a successful save means there is no unsaved draft anymore; clear
// any pre-existing draft record so the "You have an unsaved description"
// warning does not stick around after saving.
UnsavedEdits.reset({ fieldName: 'cardDescription', docId: this._id });
},
// Pressing Ctrl+Enter should submit the form
'keydown form textarea'(evt, tpl) {
+26 -30
View File
@@ -129,50 +129,55 @@
box-sizing: border-box;
}
/* Desktop mode: position card below board header */
/* Desktop mode: position card below board header.
#6465: the floating window used to span nearly the whole board (insets
20px/20px from BOTH edges, top-left anchored) — right on top of the card it
belongs to, with big rounded borders. Dock it to the END edge (right in LTR)
like the classic 6.09 side panel instead: the board and the card stay
visible, and the drag handle still moves it anywhere. */
body.desktop-mode .card-details:not(.card-details-popup) {
position: fixed;
width: auto;
max-width: 800px;
flex-basis: auto;
border-radius: 8px;
border-radius: 3px;
z-index: 2001;
}
/* Default position for first card or when dragged */
body.desktop-mode .card-details:not(.card-details-popup):not([style*="left"]):not([style*="top"]) {
top: 8px;
inset-inline-start: 20px;
inset-inline-end: 20px;
bottom: 20px;
inset-inline-start: auto;
inset-inline-end: 8px;
width: min(650px, 90vw);
bottom: 8px;
}
/* Stagger positions for multiple cards using nth-of-type */
/* Stagger positions for multiple cards using nth-of-type (from the end edge) */
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(1) {
top: 8px;
inset-inline-start: 20px;
inset-inline-end: 8px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(2) {
top: 38px;
inset-inline-start: 50px;
inset-inline-end: 38px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(3) {
top: 68px;
inset-inline-start: 80px;
inset-inline-end: 68px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(4) {
top: 98px;
inset-inline-start: 110px;
inset-inline-end: 98px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(5) {
top: 128px;
inset-inline-start: 140px;
inset-inline-end: 128px;
}
/* For expanded cards, set dimensions */
body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-collapsed) {
inset-inline-end: 20px;
bottom: 20px;
bottom: 8px;
}
/* Collapsed card state - hide content and set height to title row only */
@@ -192,7 +197,9 @@ body.desktop-mode .card-details.card-details-collapsed {
}
.card-details .card-details-canvas {
width: auto;
padding: 0 2.5vw;
/* #6465: fixed 20px as in 6.09 — 2.5vw was ~48px of white border per side
on a FullHD screen. */
padding: 0 20px;
}
.card-details .card-details-header {
margin: 0 -20px 5px;
@@ -300,25 +307,14 @@ body.desktop-mode .card-details.card-details-collapsed {
float: inline-end;
}
/* Unify all card text to match title size */
/* #6465: the "unify all card text to match title size" block (v8.18) forced
EVERY element in card details to the base font size with line-height 1.5,
flattening the 6.09 hierarchy where labels/metadata were smaller than body
text — a big part of the "card window is WAAAY too big" complaint. Removed:
components keep their own sizes again. */
.card-details {
font-size: 1em;
}
.card-details p,
.card-details span,
.card-details div,
.card-details a,
.card-details label,
.card-details input,
.card-details textarea,
.card-details select,
.card-details button,
.card-details .card-details-item-title,
.card-details .card-label,
.card-details .viewer {
font-size: inherit;
line-height: 1.5;
}
.card-details .card-details-header .card-details-watch {
font-size: 17px;
padding-inline-start: 7px;
+22 -2
View File
@@ -867,6 +867,12 @@ Template.cardDetails.events({
// permission denial that otherwise vanished with no feedback.
try {
await card.setDescription(description);
// #6455: a successful save means there is no unsaved draft anymore.
// Clear the record explicitly instead of relying on the inlined form's
// close-time draft/description comparison, which can run before the
// updated description is visible and leave a stale "You have an unsaved
// description" warning behind.
UnsavedEdits.reset({ fieldName: 'cardDescription', docId: card._id });
} catch (error) {
alert(error?.reason || error?.message || 'Failed to save description');
}
@@ -1367,10 +1373,24 @@ Template.inlinedCardDescription.onCreated(function () {
this._close = (isReset = false) => {
if (this.isOpen.get() && !isReset) {
const draft = (this._getValue() || '').trim();
// #6455: normalize BOTH sides the same way before comparing. The textarea
// value has per-line trailing whitespace stripped (_getValue), while
// getDescription() returns the raw stored text — or null for an empty
// description. Comparing them raw made the comparison permanently unequal
// for descriptions containing Markdown " \n" hard breaks (or after
// emptying the description), re-creating the phantom draft on every save.
const normalize = (s) => (s || '').replaceAll(/[ \f\r\t\v]+$/gm, '').trim();
const draft = normalize(this._getValue());
const card = getCurrentCardFromContext();
if (card && draft !== card.getDescription()) {
if (card && draft !== normalize(card.getDescription())) {
UnsavedEdits.set(this._getUnsavedEditKey(), this._getValue());
} else {
// #6455: closing with the draft equal to the saved description must
// REMOVE any pre-existing unsaved-edits record. Previously this branch
// did nothing, so once a draft record existed, pressing Save ("View it"
// -> Save) could never clear the "You have an unsaved description"
// warning — only Discard could.
UnsavedEdits.reset(this._getUnsavedEditKey());
}
}
this.isOpen.set(false);
+4 -1
View File
@@ -208,7 +208,10 @@ template(name="listMorePopup")
span {{_ 'link-list'}}
= ' '
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
input.inline-input(type="text" readonly value="{{ rootUrl }}")
//- #6459: was {{ rootUrl }}, a helper that does not exist anywhere, so the
//- link input was always empty. absoluteUrl resolves a URL via a card on
//- this list.
input.inline-input(type="text" readonly value="{{ absoluteUrl }}")
| {{_ 'added'}}
span.date(title=list.createdAt) {{ displayDate createdAt 'LLL' }}
//unless currentUser.isWorker
+9 -5
View File
@@ -290,15 +290,19 @@ body:not(.board-view) #header #header-main-bar h1 {
}
/* Zoom Controls in Header */
/* #6465: compact fixed-pixel pill. The vh/vw padding+shadow made the pill
taller than the 28px overflow-hidden quick-access row, so it rendered cut
off at every window size. */
#header-quick-access .zoom-controls {
display: flex;
align-items: center;
gap: 0.5vw;
gap: 4px;
background: rgba(255, 255, 255, 0.9);
padding: 0.5vh 1vw;
border-radius: 0.5vw;
box-shadow: 0 0.2vh 0.5vh rgba(0,0,0,0.1);
margin: 0 1vw;
padding: 2px 8px;
border-radius: 10px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
margin: 0 8px;
max-height: 24px;
float: inline-start;
}
+17 -7
View File
@@ -157,8 +157,14 @@ input,
select,
textarea,
button {
font: clamp(14px, 2.5vw, 18px) Roboto, Poppins, "Helvetica Neue", Arial, Helvetica, sans-serif;
line-height: 1.4;
/* #6465: back to the WeKan 6.09 desktop density. The clamp() introduced in
v7.98 (20e9cf114) and raised in v8.02 (67b078b80) resolved to 18px on any
window wider than 720px — +28.5% on EVERY font, input and button versus
6.09's 14px — which is the "everything is way too big" regression. Mobile
readability is handled by the mobile media queries, not by inflating the
desktop base font. */
font: 14px Roboto, Poppins, "Helvetica Neue", Arial, Helvetica, sans-serif;
line-height: 18px;
color: #4d4d4d;
/* Improve text rendering */
-webkit-font-smoothing: antialiased;
@@ -217,8 +223,10 @@ body.mobile-mode #content {
margin-top: 30vh;
}
#content > .wrapper {
margin-top: 1vh;
padding: 2vh 2vw;
/* #6465: 6.09 values; 2vh/2vw padding was ~21x38px on FullHD — the huge
empty header band on the All Boards page. */
margin-top: 10px;
padding: 15px;
}
#modal {
position: absolute;
@@ -264,13 +272,15 @@ body.mobile-mode #content {
float: inline-end;
font-size: clamp(18px, 4vw, 24px);
}
/* #6465: heading sizes back to the 6.09 values (22/18/16px) — the vw clamps
resolved to 24/20/18px on desktop and compounded the density regression. */
h1 {
font-size: clamp(18px, 4vw, 24px);
font-size: 22px;
line-height: 1.2em;
margin: 0 0 1vh;
}
h2 {
font-size: clamp(16px, 3.5vw, 20px);
font-size: 18px;
line-height: 1.2em;
margin: 0 0 0.8vh;
}
@@ -278,7 +288,7 @@ h3,
h4,
h5,
h6 {
font-size: clamp(14px, 3vw, 18px);
font-size: 16px;
line-height: 1.25em;
margin: 0 0 0.6vh;
}
@@ -134,7 +134,11 @@ Template.boardActions.events({
// documents land in minimongo limbo when the wizard subscription stops.
Meteor.call('rules.createRule', boardId, ruleName, trigger, {
actionType,
listTitle: '*',
// #6472: was `listTitle: '*'` since the original rules implementation,
// but performAction reads action.listName/action.swimlaneName — so every
// generic "move to top/bottom" rule created by this wizard did nothing.
listName: '*',
swimlaneName: '*',
boardId,
desc,
});
+32 -6
View File
@@ -1,9 +1,6 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import Papa from 'papaparse';
import Actions from '/models/actions';
import Rules from '/models/rules';
import Triggers from '/models/triggers';
const RULES_FORMAT = 'wekan-rules-1.0.0';
const STRIP_FIELDS = ['_id', 'boardId', 'createdAt', 'modifiedAt', 'updatedAt'];
@@ -33,14 +30,43 @@ function collectBoardRules(boardId) {
.filter(Boolean);
}
// #6472: the trigger matcher (server/rulesHelper.js buildMatchingFieldsMap)
// queries every matching field of the trigger type with {$in: [value, '*']} —
// a trigger DOCUMENT that lacks one of those fields can never match. Hand-
// written or third-party JSON often omits fields like userId, so default every
// known matching field to the '*' wildcard when absent. Extra fields on
// trigger types that do not use them are harmless (never queried).
const TRIGGER_MATCHING_FIELDS = [
'userId', 'username', 'cardTitle', 'listName', 'oldListName',
'swimlaneName', 'checklistName', 'checklistItemName', 'labelId',
'attachmentName',
];
function normalizeTrigger(trigger) {
const out = { ...trigger };
TRIGGER_MATCHING_FIELDS.forEach(f => {
if (out[f] === undefined || out[f] === null || out[f] === '') out[f] = '*';
});
return out;
}
// Insert an array of {title, trigger, action} onto the given target board.
function importRules(rulesArray, boardId) {
let count = 0;
(rulesArray || []).forEach(entry => {
if (!entry || !entry.trigger || !entry.action) return;
const triggerId = Triggers.insert({ ...stripDoc(entry.trigger), boardId });
const actionId = Actions.insert({ ...stripDoc(entry.action), boardId });
Rules.insert({ title: entry.title || 'Imported rule', triggerId, actionId, boardId });
// #6472: create via the rules.createRule server method (like the rules
// wizard since #5536) instead of three optimistic client inserts — those
// are rejected by the board-admin-only allow() rules for non-admins and
// land in minimongo limbo: the imported rule LOOKS created but never
// exists on the server, so it silently does nothing until it vanishes.
Meteor.call(
'rules.createRule',
boardId,
entry.title || 'Imported rule',
normalizeTrigger(stripDoc(entry.trigger)),
stripDoc(entry.action),
);
count += 1;
});
return count;
+30 -18
View File
@@ -146,21 +146,27 @@ template(name="orgGeneral")
th
| {{_ 'org-shared-templates'}}
br
a.js-org-feature-all(href="#", data-feature="orgSharedTemplates", data-value="true") {{_ 'select-all'}}
| /
a.js-org-feature-all(href="#", data-feature="orgSharedTemplates", data-value="false") {{_ 'unselect-all'}}
a.js-org-feature-all(href="#", data-feature="orgSharedTemplates", data-value="true", title="{{_ 'select-all'}}")
i.fa.fa-check-square-o
|
a.js-org-feature-all(href="#", data-feature="orgSharedTemplates", data-value="false", title="{{_ 'unselect-all'}}")
i.fa.fa-square-o
th
| {{_ 'org-propagate-members-to-boards'}}
br
a.js-org-feature-all(href="#", data-feature="orgPropagateMembersToBoards", data-value="true") {{_ 'select-all'}}
| /
a.js-org-feature-all(href="#", data-feature="orgPropagateMembersToBoards", data-value="false") {{_ 'unselect-all'}}
a.js-org-feature-all(href="#", data-feature="orgPropagateMembersToBoards", data-value="true", title="{{_ 'select-all'}}")
i.fa.fa-check-square-o
|
a.js-org-feature-all(href="#", data-feature="orgPropagateMembersToBoards", data-value="false", title="{{_ 'unselect-all'}}")
i.fa.fa-square-o
th
| {{_ 'org-sync-members-from-auth'}}
br
a.js-org-feature-all(href="#", data-feature="orgSyncMembersFromAuth", data-value="true") {{_ 'select-all'}}
| /
a.js-org-feature-all(href="#", data-feature="orgSyncMembersFromAuth", data-value="false") {{_ 'unselect-all'}}
a.js-org-feature-all(href="#", data-feature="orgSyncMembersFromAuth", data-value="true", title="{{_ 'select-all'}}")
i.fa.fa-check-square-o
|
a.js-org-feature-all(href="#", data-feature="orgSyncMembersFromAuth", data-value="false", title="{{_ 'unselect-all'}}")
i.fa.fa-square-o
tbody
tr
each org in orgList
@@ -187,21 +193,27 @@ template(name="teamGeneral")
th
| {{_ 'team-shared-templates'}}
br
a.js-team-feature-all(href="#", data-feature="teamSharedTemplates", data-value="true") {{_ 'select-all'}}
| /
a.js-team-feature-all(href="#", data-feature="teamSharedTemplates", data-value="false") {{_ 'unselect-all'}}
a.js-team-feature-all(href="#", data-feature="teamSharedTemplates", data-value="true", title="{{_ 'select-all'}}")
i.fa.fa-check-square-o
|
a.js-team-feature-all(href="#", data-feature="teamSharedTemplates", data-value="false", title="{{_ 'unselect-all'}}")
i.fa.fa-square-o
th
| {{_ 'team-propagate-members-to-boards'}}
br
a.js-team-feature-all(href="#", data-feature="teamPropagateMembersToBoards", data-value="true") {{_ 'select-all'}}
| /
a.js-team-feature-all(href="#", data-feature="teamPropagateMembersToBoards", data-value="false") {{_ 'unselect-all'}}
a.js-team-feature-all(href="#", data-feature="teamPropagateMembersToBoards", data-value="true", title="{{_ 'select-all'}}")
i.fa.fa-check-square-o
|
a.js-team-feature-all(href="#", data-feature="teamPropagateMembersToBoards", data-value="false", title="{{_ 'unselect-all'}}")
i.fa.fa-square-o
th
| {{_ 'team-sync-members-from-auth'}}
br
a.js-team-feature-all(href="#", data-feature="teamSyncMembersFromAuth", data-value="true") {{_ 'select-all'}}
| /
a.js-team-feature-all(href="#", data-feature="teamSyncMembersFromAuth", data-value="false") {{_ 'unselect-all'}}
a.js-team-feature-all(href="#", data-feature="teamSyncMembersFromAuth", data-value="true", title="{{_ 'select-all'}}")
i.fa.fa-check-square-o
|
a.js-team-feature-all(href="#", data-feature="teamSyncMembersFromAuth", data-value="false", title="{{_ 'unselect-all'}}")
i.fa.fa-square-o
tbody
tr
each team in teamList
+8 -2
View File
@@ -117,11 +117,17 @@
}
/* Force tables to always trigger horizontal scrolling */
.setting-content .content-body .main-body table td,
.setting-content .content-body .main-body table th {
.setting-content .content-body .main-body table td {
white-space: nowrap;
min-width: 120px;
}
/* #6465: headers may wrap — nowrap on long header labels (e.g. the org/team
feature columns) blew the column widths up to the whole label length. */
.setting-content .content-body .main-body table th {
white-space: normal;
min-width: 120px;
max-width: 240px;
}
/* Ensure there's enough content to trigger vertical scrolling */
.setting-content .content-body .main-body {
+36 -22
View File
@@ -331,15 +331,25 @@ Lists.helpers({
if (!swimlaneId) {
return undefined;
}
// Only the first swimlane surfaces orphaned cards.
const pick = (swimlanes) => {
if (!swimlanes || !swimlanes.length || swimlanes[0]._id !== swimlaneId) {
return undefined;
}
return swimlanes.map(s => s._id).filter(id => id !== swimlaneId);
};
const swimlanes = ReactiveCache.getSwimlanes(
{ boardId: this.boardId, archived: false },
{ sort: ['sort'] },
);
// Only the first swimlane surfaces orphaned cards.
if (!swimlanes.length || swimlanes[0]._id !== swimlaneId) {
return undefined;
// On the SERVER ReactiveCache.getSwimlanes returns a Promise; reading
// .length off it made this always return undefined there (the orphan
// fallback silently never applied server-side). Resolve it instead; the
// client path stays synchronous.
if (swimlanes && typeof swimlanes.then === 'function') {
return swimlanes.then(pick);
}
return swimlanes.map(s => s._id).filter(id => id !== swimlaneId);
return pick(swimlanes);
},
cards(swimlaneId) {
@@ -348,28 +358,32 @@ Lists.helpers({
// bare top-level `$or`, so it never competes with the board Filter's own
// top-level `$or` when the two selectors are combined.
// #6443: also surface orphaned cards in the first swimlane.
const selector = listCardsSelector(
this._id,
swimlaneId,
this.orphanedCardsSwimlaneIds(swimlaneId),
);
const filterSelector =
typeof Filter !== 'undefined' && typeof Filter.mongoSelector === 'function'
? Filter.mongoSelector(selector)
: selector;
const ret = ReactiveCache.getCards(filterSelector, { sort: ['sort'] });
return ret;
const query = (orphanedIds) => {
const selector = listCardsSelector(this._id, swimlaneId, orphanedIds);
const filterSelector =
typeof Filter !== 'undefined' && typeof Filter.mongoSelector === 'function'
? Filter.mongoSelector(selector)
: selector;
return ReactiveCache.getCards(filterSelector, { sort: ['sort'] });
};
const orphaned = this.orphanedCardsSwimlaneIds(swimlaneId);
if (orphaned && typeof orphaned.then === 'function') {
return orphaned.then(query); // server (async ReactiveCache)
}
return query(orphaned);
},
cardsUnfiltered(swimlaneId) {
// Same swimlane-membership fallback as cards() (#6441/#6443), without the Filter.
const selector = listCardsSelector(
this._id,
swimlaneId,
this.orphanedCardsSwimlaneIds(swimlaneId),
);
const ret = ReactiveCache.getCards(selector, { sort: ['sort'] });
return ret;
const query = (orphanedIds) =>
ReactiveCache.getCards(listCardsSelector(this._id, swimlaneId, orphanedIds), {
sort: ['sort'],
});
const orphaned = this.orphanedCardsSwimlaneIds(swimlaneId);
if (orphaned && typeof orphaned.then === 'function') {
return orphaned.then(query); // server (async ReactiveCache)
}
return query(orphaned);
},
allCards(swimlaneId) {
+10 -1
View File
@@ -408,7 +408,16 @@ var makeTagConstructor = function (tagName) {
var attrs = arguments.length && arguments[0]; // 19
if (attrs && (typeof attrs === 'object')) { // 20
// Treat vanilla JS object as an attributes dictionary. // 21
if (! HTML.isConstructedObject(attrs)) { // 22
// Patched (WeKan #6459): an ARRAY first argument is tag CONTENT, never an
// attributes dictionary. The vm-sandbox patch below made
// isConstructedObject(Array) === false (upstream htmljs returns true), so a
// tag whose inline text compiles to an array — e.g. the jade line
// `label {{_ 'source-board'}}:`, i.e. [TemplateTag, ":"] — got its content
// array assigned to instance.attrs. blaze-tools then serialized it as
// HTML.Attrs(...), and at runtime Blaze._expandAttributes threw "The basic
// TransformingVisitor does not support foreign objects in attributes",
// rendering the whole cardMorePopup (with the Delete option) empty.
if (! Array.isArray(attrs) && ! HTML.isConstructedObject(attrs)) { // 22
instance.attrs = attrs; // 23
i++; // 24
} else if (attrs instanceof HTML.Attrs) { // 25
+9
View File
@@ -69,6 +69,15 @@ FERRET_PID=""
stop_ferret() { [ -n "$FERRET_PID" ] && kill "$FERRET_PID" 2>/dev/null || true; }
trap 'stop_ferret; exit 0' INT TERM
# #6467/#6468: with FerretDB (no oplog) Meteor observes queries by POLLING, and
# its defaults (re-poll 50 ms after any write, at least every 10 s) hammer the
# database. Calmer polling defaults; other users' changes may take ~2 s extra to
# appear. Overridable by exporting different values before running this script.
if [ "$want_ferret" = true ]; then
export METEOR_POLLING_THROTTLE_MS="${METEOR_POLLING_THROTTLE_MS:-2000}"
export METEOR_POLLING_INTERVAL_MS="${METEOR_POLLING_INTERVAL_MS:-30000}"
fi
while true; do
if [ "$want_ferret" = true ]; then
export DO_NOT_TRACK=1 FERRETDB_TELEMETRY=disable
+8 -1
View File
@@ -1239,7 +1239,14 @@ async function run() {
console.log('[migrate] ✓ Migration completed successfully.');
} else {
state.phase = 'completed-with-errors';
state.success = totalErrors < 10; // treat <10 isolated errors as success
// #6466: per-item errors (one unparsable document, one attachment/avatar that
// fails to extract) must NOT fail the whole migration — everything that DID
// copy is valid. The old `totalErrors < 10` threshold turned ten cosmetic
// per-item errors into a non-zero exit, which made the snap's
// migration-control discard the fully-copied FerretDB SQLite and leave WeKan
// serving 502 Bad Gateway. Fatal conditions (disk-full, unreachable
// source/target) already set state.success = false and never reach here.
if (state.success !== false) state.success = true;
console.log(`[migrate] ⚠ Migration done with ${totalErrors} error(s). See dashboard.`);
}
state.finishedAt = new Date().toISOString();
+6 -1
View File
@@ -72,7 +72,12 @@ Meteor.methods({
// to board X" / "link card to board X"). Let it win over the source boardId
// so cross-board move/link rules created through this method keep their
// target — only fall back to the source board when the action omits one.
const actionId = await Actions.insertAsync({ boardId, ...clean(action) });
// #6472: "omits" must include an EMPTY boardId (a not-yet-loaded board
// select posts ''), which the spread would otherwise let override the
// fallback and make every list lookup in the action fail silently.
const actionDoc = { boardId, ...clean(action) };
if (!actionDoc.boardId) actionDoc.boardId = boardId;
const actionId = await Actions.insertAsync(actionDoc);
const ruleDoc = {
title: title || 'Rule',
triggerId,
+40 -13
View File
@@ -157,7 +157,11 @@ export const RulesHelper = {
) {
let list;
let listId;
if (action.listName === '*') {
if (action.listName === '*' || !action.listName) {
// #6472: rules created by the classic wizard's generic "move to
// top/bottom" action stored the field as listTitle (never read here),
// so action.listName was undefined and the exact-title lookup below
// always failed. An unset listName means "the card's current list".
list = await card.list();
if (boardId !== action.boardId) {
list = await ReactiveCache.getList({ title: list.title, boardId: action.boardId });
@@ -168,11 +172,22 @@ export const RulesHelper = {
boardId: action.boardId,
});
}
if (list === undefined) {
listId = '';
} else {
listId = list._id;
// #6472: an unresolved list (typo'd/renamed/case-mismatched listName, or
// the list only exists on another board) crashed below on
// list.cardsUnfiltered — the error was swallowed by the activity hook, so
// the rule silently "did nothing". Fall back to the card's own list so
// moveCardToTop/Bottom still does the sensible thing within the card's
// current list.
let fellBackToCardList = false;
if (!list) {
console.warn(
`WeKan rule action ${action.actionType}: list "${action.listName}" not found on board ${action.boardId}; using the card's current list instead.`,
);
list = await card.list();
if (!list) return;
fellBackToCardList = true;
}
listId = list._id;
let swimlane;
let swimlaneId;
@@ -201,16 +216,28 @@ export const RulesHelper = {
await getDestBoardDefaultSwimlane(action.boardId),
);
// #6472: the fallback list is on the CARD's board, so the move must stay
// there too — an action.boardId/swimlaneId from a different board would
// produce an inconsistent card. Also move within the card's own swimlane.
let destBoardId = action.boardId;
if (fellBackToCardList) {
destBoardId = list.boardId;
swimlaneId = card.swimlaneId;
}
// #6472: an empty destination (no cards in that list+swimlane yet) made
// Math.min()/Math.max() of no arguments return ±Infinity, writing a
// corrupt sort value; a non-finite stored sort would poison it again.
const destSorts = (await list.cardsUnfiltered(swimlaneId))
.map(c => c.sort)
.filter(Number.isFinite);
if (action.actionType === 'moveCardToTop') {
const minOrder = Math.min(
...(await list.cardsUnfiltered(swimlaneId)).map(c => c.sort),
);
await withUserId(activity.userId, () => card.move(action.boardId, swimlaneId, listId, minOrder - 1));
const minOrder = destSorts.length ? Math.min(...destSorts) : 0;
await withUserId(activity.userId, () => card.move(destBoardId, swimlaneId, listId, minOrder - 1));
} else {
const maxOrder = Math.max(
...(await list.cardsUnfiltered(swimlaneId)).map(c => c.sort),
);
await withUserId(activity.userId, () => card.move(action.boardId, swimlaneId, listId, maxOrder + 1));
const maxOrder = destSorts.length ? Math.max(...destSorts) : 0;
await withUserId(activity.userId, () => card.move(destBoardId, swimlaneId, listId, maxOrder + 1));
}
}
if (action.actionType === 'sendEmail') {
+10 -1
View File
@@ -833,7 +833,16 @@ async function run() {
await client.close();
state.phase = state.errors.length ? 'completed-with-errors' : 'completed';
state.success = state.errors.length < 10;
// #6466: per-item errors (a document that fails EJSON parse — "no valid json" —
// or a single attachment/avatar that fails to extract) must NOT fail the whole
// migration: everything that DID copy is valid and consistent. The old
// `state.errors.length < 10` threshold meant ten cosmetic per-item errors
// (e.g. the "Avatars Errors" list) exited non-zero, which made migration-control
// discard the fully-copied FerretDB SQLite and leave the snap serving 502 Bad
// Gateway forever. Genuinely fatal conditions (disk-full abort, unusable source
// tools, unreachable target) already returned early with state.success = false;
// reaching this point means the run completed.
if (state.success !== false) state.success = true;
saveCheckpoint(true);
writeStatus();
}
+35 -3
View File
@@ -239,6 +239,9 @@ finish_success() {
log "FerretDB SQLite verified (${sq:-?})${wal:+; WAL log present}."
log "Migration succeeded. Switching to FerretDB (SQLite); MongoDB will be shut down."
touch "$MARKER"
# #6458/#6466: clear mongodb-control's start-failure counter — the migration
# completing is exactly the recovery those failures were counting toward.
rm -f "$SNAP_COMMON/.mongod-start-failures" 2>/dev/null || true
snapctl set database=ferretdb 2>/dev/null || true
# CRUCIAL: an INTERNAL `snapctl set` does NOT re-run the configure hook, so we must flip
# the database services here ourselves — exactly as snap/hooks/configure's ferretdb
@@ -299,6 +302,24 @@ fall_back_to_mongodb() { # $1 = human-readable reason
snapctl set migrate=off 2>/dev/null || true
exec bash "$SNAP/bin/mongodb-control"
}
# #6466: failure handler for the MongoDB 3.x branch. For a 3.x source there is NO
# working MongoDB fallback: the only service mongod is 7.x, which cannot open 3.x
# data files ("This version of MongoDB is too recent ... Try MongoDB 4.2 or
# earlier"), so fail_and_run_mongodb used to delete the (possibly fully-copied)
# FerretDB SQLite over a handful of per-item errors and then strand the snap in a
# mongod crash loop serving 502 Bad Gateway. Instead: KEEP the partial FerretDB
# SQLite and its checkpoint (they are consistent with each other and resume-safe),
# leave auto-migration ON, and exit non-zero so snapd restarts the service and the
# next start RESUMES the migration from the checkpoint. The source MongoDB data is
# never modified either way.
fail_3x_keep_progress() { # $1 = importer exit code
log "ERROR: migration did not complete (importer rc=$1)."
log "MongoDB 7 cannot serve this MongoDB 3.x data, so there is no MongoDB fallback."
log "Keeping the partial FerretDB SQLite in $SQLITE_DIR and the checkpoint; the migration RESUMES from there on the next start, or run: snap run wekan.migrate"
trap - EXIT INT TERM
cleanup_all
exit 1
}
# ── Any modern MongoDB (6/7, incl. a DB earlier migrated from 3.x)? ───────────
# The WeKan snap moves every existing MongoDB database — MongoDB 3, MongoDB 7, or any
@@ -306,7 +327,16 @@ fall_back_to_mongodb() { # $1 = human-readable reason
# CollectionFS + Meteor-Files GridFS attachments/avatars onto the filesystem
# (files/attachments, files/avatars). Version only decides HOW we read the source.
log "Checking whether mongod 7 can open the existing data ..."
if start_mongod "$SNAP/bin/mongod" "" "--wiredTigerCacheSizeGB ${SRC_WT_CACHE_GB}" && ready_via_node; then
# #6458: MongoDB >= 5.0 x86_64 requires AVX. Without it the mongod 7 probe below
# can only die with SIGILL, so skip it and go straight to the MongoDB 3.x reader —
# the bundled 3.2 tools, the Node importer and FerretDB are all AVX-free, so the
# migration itself works fine on such CPUs.
HAVE_AVX=1
if [ "$(uname -m)" = "x86_64" ] && ! grep -qw avx /proc/cpuinfo 2>/dev/null; then
HAVE_AVX=0
log "CPU has no AVX support: skipping the mongod 7 probe (it would SIGILL); trying the MongoDB 3.x reader directly."
fi
if [ "$HAVE_AVX" = "1" ] && start_mongod "$SNAP/bin/mongod" "" "--wiredTigerCacheSizeGB ${SRC_WT_CACHE_GB}" && ready_via_node; then
if [ ! -x "$FERRETDB" ] || [ ! -x "$NODE" ] || [ ! -f "$IMPORTER_MODERN" ]; then
fall_back_to_mongodb "Migration tools unavailable; cannot migrate this modern MongoDB to FerretDB."
fi
@@ -361,8 +391,10 @@ rc=$?
if [ "$rc" -eq 0 ] && finish_success; then :
else
[ "$rc" -ge 128 ] && resume_next_start "$rc"
log "ERROR: migration did not complete (importer rc=$rc). Keeping WeKan on MongoDB."
fail_and_run_mongodb
# #6466: NOT fail_and_run_mongodb — mongod 7 cannot serve this 3.x data, so that
# path stranded the snap in a mongod crash loop with 502s AND deleted the copied
# data. Keep the progress and resume on the next start instead.
fail_3x_keep_progress "$rc"
fi
# temp FerretDB + source mongod are stopped by the EXIT trap.
+89 -3
View File
@@ -184,6 +184,75 @@ cleanup() {
}
trap cleanup EXIT
# ── #6458/#6466: decide a real outcome when mongod cannot start ──────────────
# mongod can be unable to start at all: SIGILL (exit 132) on x86_64 CPUs without
# AVX (MongoDB >= 5.0 requires AVX — #6458), or "This version of MongoDB is too
# recent to start up on the existing data files" when the data is still MongoDB
# 3.x after a failed/paused migration (#6466). Previously the exit status of the
# mongod fork was never checked: the script pinged a dead port for ~10 minutes,
# snapd restarted the service, and the cycle repeated forever while WeKan served
# 502 Bad Gateway. Instead: switch to a COMPLETED FerretDB migration if one
# exists, else (re)run the MongoDB → FerretDB migration (FerretDB is pure Go +
# SQLite — no AVX needed; MongoDB 3.x data is read with the bundled 3.2 tools),
# with a counter so a persistently failing migration cannot ping-pong forever.
MONGOD_FAIL_COUNT_FILE="${SNAP_COMMON}/.mongod-start-failures"
handle_mongod_start_failure() {
local rc="$1"
local svc="${SNAP_INSTANCE_NAME:-${SNAP_NAME:-wekan}}"
echo "ERROR: mongod failed to start (exit code ${rc})."
if [ "$rc" -eq 132 ]; then
echo "Exit 132 = SIGILL (Illegal instruction): MongoDB 5.0+ x86_64 binaries require a CPU"
echo "with AVX support, and this CPU has none — the bundled MongoDB 7 can never run here."
echo "FerretDB (pure Go + SQLite) has no AVX requirement and is the supported fallback."
fi
echo "Last mongod log lines:"
tail -n 5 "$MONGO_LOG_FILE" 2>/dev/null || true
# A COMPLETED FerretDB migration exists (marker + non-empty SQLite): the snap
# should be running FerretDB anyway — switch now instead of crash-looping.
# (A PARTIAL migration — SQLite present but no marker — is NOT switched to.)
if [ -f "$SNAP_COMMON/.migration-to-ferretdb-done" ] && \
bash "$SNAP/bin/ferretdb-has-data" "$SNAP_COMMON/files/db" 2>/dev/null; then
echo "A completed FerretDB migration exists; switching to database=ferretdb."
snapctl set database=ferretdb 2>/dev/null || true
snapctl start --enable "${svc}.ferretdb" 2>/dev/null || true
snapctl restart "${svc}.ferretdb" 2>/dev/null || true
snapctl restart "${svc}.wekan" 2>/dev/null || true
snapctl stop --disable "${svc}.mongodb" 2>/dev/null || true
exit 0
fi
local fails=0
[ -f "$MONGOD_FAIL_COUNT_FILE" ] && fails="$(cat "$MONGOD_FAIL_COUNT_FILE" 2>/dev/null || echo 0)"
case "$fails" in (*[!0-9]*|'') fails=0;; esac
fails=$((fails + 1))
echo "$fails" > "$MONGOD_FAIL_COUNT_FILE"
# MongoDB data that this mongod cannot serve: the only way forward is the
# MongoDB → FerretDB migration. Re-enable and run it now (it RESUMES from its
# checkpoint if one exists), unless it keeps failing too (counter guards
# against a migration↔mongod restart loop).
if [ "$fails" -le 3 ] && [ ! -f "$SNAP_COMMON/.migration-to-ferretdb-done" ] && \
{ [ -f "$SNAP_COMMON/WiredTiger" ] || ls "$SNAP_COMMON"/collection-*.wt >/dev/null 2>&1; }; then
echo "mongod cannot serve this data; running the MongoDB -> FerretDB migration instead (attempt ${fails}/3)."
snapctl set migrate=on 2>/dev/null || true
rm -f "${SNAP_COMMON}/mongodb.pid"
exec bash "$SNAP/bin/migration-control"
fi
echo "mongod cannot start, and the MongoDB -> FerretDB migration has not completed (attempt ${fails})."
echo "Fix the problem, then retry/resume the migration with: snap run wekan.migrate"
echo "Diagnostics: ${MONGO_LOG_FILE} and ${SNAP_COMMON}/migration-*.log"
exit 1
}
# #6458: pre-flight AVX check — without AVX, starting mongod below would only
# produce the SIGILL crash loop, so fail over right away with a clear message.
if [ "$(uname -m)" = "x86_64" ] && ! grep -qw avx /proc/cpuinfo 2>/dev/null; then
echo "ERROR: This x86_64 CPU has no AVX support; MongoDB 7 would exit with SIGILL."
handle_mongod_start_failure 132
fi
# Check if MongoDB is already running
if check_mongodb_running; then
echo "MongoDB is already running. Exiting to prevent multiple instances."
@@ -223,7 +292,11 @@ $SNAP/bin/mongod \
--setParameter logicalSessionRefreshMillis=900000 \
--setParameter localLogicalSessionTimeoutMinutes=45 \
--replSet rs0 --oplogSize 20480 --fork \
--pidfilepath="$TEMP_PID_FILE"
--pidfilepath="$TEMP_PID_FILE" \
|| handle_mongod_start_failure $?
# ^ #6458/#6466: with --fork, mongod's parent only returns 0 once the child is up;
# a SIGILL (no AVX) or "data files too recent" (3.x data) failure returns non-zero
# and previously fell through to ~10 minutes of pinging a dead port, forever.
# Wait for MongoDB to accept connections (up to 30 s). db-eval uses the bundled
# Node.js + `mongodb` driver; directConnection reaches this node before the
@@ -243,6 +316,9 @@ until "$DB_EVAL" ping "$_RS_URL" >/dev/null 2>&1; do
fi
sleep 1
done
# mongod is answering: clear the start-failure counter (see
# handle_mongod_start_failure) so past failures don't count against future ones.
[ $_wait -lt 90 ] && rm -f "$MONGOD_FAIL_COUNT_FILE"
# Check replica set status and current member host (prints OK:<host> or ERROR:<msg>)
_rs_check=$("$DB_EVAL" rs-conf-host "$_RS_URL" 2>/dev/null)
@@ -277,8 +353,12 @@ if [ -f "$TEMP_PID_FILE" ]; then
fi
sleep 1
# Final start of MongoDB in foreground (this becomes the main process)
exec $SNAP/bin/mongod \
# Final start of MongoDB in foreground (this stays the main process; #6458/#6466:
# not `exec` anymore, so an IMMEDIATE startup crash — SIGILL on non-AVX CPUs,
# incompatible data files — is caught below instead of snapd blindly restarting
# this service into the same crash forever)
_mongod_start_ts=$(date +%s)
$SNAP/bin/mongod \
--dbpath="$MONGO_DATA_DIR" \
--logpath="$MONGO_LOG_FILE" \
--logappend $BIND_OPTIONS \
@@ -287,3 +367,9 @@ exec $SNAP/bin/mongod \
--setParameter logicalSessionRefreshMillis=900000 \
--setParameter localLogicalSessionTimeoutMinutes=45 \
--replSet rs0 --oplogSize 20480
_mongod_rc=$?
if [ $_mongod_rc -ne 0 ] && [ $(( $(date +%s) - _mongod_start_ts )) -lt 15 ]; then
# Died within seconds of starting: a startup failure, not a normal shutdown.
handle_mongod_start_failure $_mongod_rc
fi
exit $_mongod_rc
+14
View File
@@ -86,6 +86,20 @@ else
fi
fi
# #6467/#6468: with FerretDB (no oplog) Meteor observes every query by POLLING.
# The Meteor defaults are aggressive — re-poll every observed query 50 ms after
# ANY write, and at least every 10 s — which multiplied into hundreds of
# re-executed queries per second on an active board and pinned FerretDB's CPU.
# Calmer defaults for the polling driver: re-poll at most every 2 s after a
# write and every 30 s otherwise. Own writes still show instantly (Meteor
# applies them to the local cache); this only delays seeing OTHER users' changes
# by up to ~2 s. Both remain overridable via snap environment settings.
if [ "ferretdb" = "${DATABASE}" ]; then
[ -z "${METEOR_POLLING_THROTTLE_MS}" ] && export METEOR_POLLING_THROTTLE_MS=2000
[ -z "${METEOR_POLLING_INTERVAL_MS}" ] && export METEOR_POLLING_INTERVAL_MS=30000
echo "FerretDB polling: METEOR_POLLING_THROTTLE_MS=${METEOR_POLLING_THROTTLE_MS} METEOR_POLLING_INTERVAL_MS=${METEOR_POLLING_INTERVAL_MS}"
fi
# If WRITABLE_PATH is not set, use $SNAP_COMMON/files
# Wekan will store attachments there.
if [ -z "$WRITABLE_PATH" ]; then