mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
Post work PR #1448
This commit is contained in:
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [1.2.6b1] - Unreleased
|
||||
|
||||
### Added
|
||||
- **Cost centers, budgets and a print ledger, for installs where somebody has to be billed (#1448, contributor @behrinml, requested in #1065)** — A print farm shared by a lab, a makerspace or a department has always been able to see what a print cost, but not to say whose budget it came out of. Bambuddy now has an optional billing layer that answers that. **Settings → Workflow → Billing** turns it on; it is off by default and nothing about an existing install changes until it is switched on. A **cost center** is a budget somebody can print against — a team, a course, a customer, a grant. Every user gets a private one automatically, so a personal install is usable the moment billing is enabled, and shared centers are created and staffed by an administrator with per-member permission to print against them. Each center carries an optional total or monthly budget; a center with no budget is explicitly unlimited rather than blocked. When a print is queued against a budgeted center, its cost is **reserved** rather than merely predicted, so a queue of ten jobs cannot each pass a check the tenth would fail — the reservation is released if the print never happens. The monthly window is configurable: pick the day it resets and the timezone that day is measured in, since a team spread across timezones otherwise disagrees about which month a print landed in. The estimate itself is computed on the server. The figure shown in the print dialog is a display hint, and the browser cannot lower it: budget enforcement uses Bambuddy's own calculation from the file's filament and the printer's rates, whatever the client sends. Completed prints are charged from the archive's measured cost, and a print that is aborted part-way is charged for the filament it actually used rather than the whole job. Every charge, deposit, withdrawal and administrative adjustment lands in a ledger on the new **Finance** page, which appears in the sidebar only while billing is on. Each charge carries a per-dispatch identity that survives restarts and reprints, so a job cannot be billed twice, and a charge that fails is reported through the UI and any configured notification provider rather than quietly not happening. Balances and budgets deliberately mean different things: a balance records what somebody has spent, and only a cost center's budget can stop a print. Personal balances count unassigned charges and the user's own private center; a print billed to a shared center does not touch the personal one. Four permissions gate the whole thing — `cost_centers:read_own`, `read_all`, `modify` and `create` — with the default Administrators group holding all four. Separately switchable, and off by default, is a **printer kill switch** that stops any print that starts on a printer without going through Bambuddy, so a farm that bills its users cannot be bypassed by sending a job straight from Bambu Studio. It stops the print, says so on screen and notifies. It also declines to act whenever it cannot prove ownership — a restart mid-print, a job Bambuddy dispatched but has not finished recording — because stopping a print is irreversible and refusing to act costs only a log line.
|
||||
- **Nest projects under a master project and see the whole programme in one place (#1264, reporter @enjoylifenow)** — A project has always been a flat thing: a build with fifty parts and a build with two got the same single row, and the only way to keep a large job legible was to split it into separate projects that then knew nothing about each other. Projects can now be nested. The project dialog has a **Parent project** picker, so an assembly can sit under the build it belongs to, at whatever depth suits the work; the picker leaves out the project itself and anything already beneath it, because nesting a project inside its own branch is a loop rather than a hierarchy. A project that has sub-projects gains a second card reporting the whole tree at once — print jobs, parts, time, filament and total cost, with progress measured against every target in the tree added together. That card is deliberately separate from the project's own figures, which keep meaning exactly what they meant before: what this project printed, not what its sub-projects did. Each sub-project listed underneath now carries its own branch's totals rather than only a percentage, so the rows add up to the card above them instead of contradicting it. On the Projects page a sub-project is drawn inside its parent's group rather than as another card somewhere in the grid, because two cards that belong together cannot show it while they sit columns apart, however they are captioned; the group is ruled in the parent's own colour and nests as deep as the projects do. A sub-project whose parent is hidden by the status filter stays where it is and says which project it belongs to instead. Two things that only became reachable once the interface could reach them were fixed along the way: a project could be moved under its own sub-project, which the API refused only when a project was made its own direct parent, and a percentage shown against a sub-project was measured differently from the same percentage on the page it linked to. Deleting a project in the middle of a tree now lifts its sub-projects up to its own parent instead of cutting them loose at the top level.
|
||||
- **Restore selected categories from a Git backup commit (#2714, contributor @jmoore-skild, requested in #2656)** — Bambuddy has pushed backups to GitHub, GitLab, Gitea and Forgejo for a long time, and every one of those commits was a restore point that nothing could read back. Recovering from a bad settings change, a rebuilt instance or a lost database meant opening the repository by hand and copying JSON into the right places, if you knew which places those were. **Settings → Backup & Restore → Restore from Git** now picks any of the twenty most recent commits and pulls back the categories you tick — K-profiles, app settings, spool inventory and print history — without touching anything you did not select. The modal previews the commit before anything is written: it shows how many items each category holds and greys out the ones that commit does not contain, so a category you only enabled last week is visibly absent from older commits rather than silently restoring nothing. **Overwrite existing entries** decides what happens when something already exists locally — off, it fills in what is missing and leaves the rest alone; on, it makes the local row match the backup. The result panel reports what actually happened per category as restored, skipped and failed, and those three always add up to the number the preview showed you, so a count that does not match the preview is a bug rather than something to interpret. Restoring never resurrects a credential: the backup carries MQTT, LDAP, Home Assistant and Prometheus secrets so that a repository is a complete record, but the restore refuses every one of them, and refuses along with them any switch that would be left pointing at a service it can no longer authenticate to — an exposed Prometheus endpoint with no token is worse than one that stays off. The keys that decide who can reach the instance at all are refused outright for the same reason — the four authentication-policy switches, and the whole LDAP family alongside them, since those name *which directory server decides who you are* rather than how the instance behaves. Authentication is reconfigured through the auth UI, which has the guards that a JSON file does not. Print archives come back as history only, since a Git backup holds metadata and never the 3MF or thumbnail bytes, and each one is returned to its owner by username rather than by user id — an id means nothing on a rebuilt instance, where it would hand one person's print history to whoever now holds that number. An archive whose owner this instance does not have lands unowned with a note saying so, rather than being attributed to a stranger; one that already exists locally keeps the owner it already has, because an owner the backup cannot name is not an instruction to take one away. K-profiles are the one category that leaves the database: they are sent to the printer over MQTT, which means the printer must be online, and writing a slot is always an overwrite there regardless of the toggle — the modal says so before you click rather than in the summary afterwards. Cloud profiles are backed up but deliberately not restorable, as writing them means writing to a Bambu or Orca account rather than to this instance. **Restoring is permissioned per category**: `github:restore` opens the dialog, and each category additionally requires the permission that owns its rows — `settings:update`, `inventory:update`, `archives:update_all` and `kprofiles:update` — so a role cannot write through a restore what it cannot write through the page that owns it. Administrators hold all of them already; a custom role built around the Backup permissions alone can open the dialog and preview a commit, but needs the owning permission for each category you want it to be able to write. Each category is committed as it completes rather than at the end, so a large restore does not hold the database against the rest of Bambuddy for the length of the run; the trade is that a failure part-way through leaves the categories that already finished in place, which the result panel reports rather than claiming nothing was restored. Translated in all locales; wiki updated. Covered by backend and frontend tests.
|
||||
- **Home Assistant sensors on the printer card, with an optional print interlock (#1148, reporter @bsaunder; #448, reporter @baudneo)** — Bambuddy could already switch a Home Assistant entity as a smart plug, but it had no way to *read* one. A printer in a home-built enclosure with a door contact, or an A1 with an aftermarket chamber thermometer, had all that data in Home Assistant and none of it in Bambuddy — the reporter's actual problem being that he could not tell whether he had left the enclosure open before starting a print from his phone. **Settings → Smart Plugs → Home Assistant Sensors** now binds any `binary_sensor` — a door, window, smoke or moisture contact — or any `sensor` that carries a reading to a printer, and its state appears on that printer's card. The wording follows Home Assistant's own device class, so a door reads Open or Closed rather than On or Off, and a thermometer reads `41.2 °C`; entities with no device class fall back to on/off, exactly as Home Assistant shows them. A sensor can be given an alert condition — on, off, above a value, below a value — which highlights it on the card and unlocks two things it would otherwise be pointless to offer. **Notify on alert** sends a notification the moment the sensor enters that state, once on the way in rather than on every poll, and not again when a flaky contact drops off the network and comes back still alerting. **Hold prints while alerting** is the part that answers the original question without anyone having to look: queued jobs for that printer wait, with a reason on the Queue page you can read at a glance ("Waiting on Enclosure Door"), and start by themselves as soon as the door shuts. Nothing is ever cancelled, and a job queued as "Any X1C" simply goes to a sibling whose sensors are clear instead of waiting behind the one that is held. The interlock is deliberately one-directional: it holds only on a sensor that was read successfully and *is* alerting, so a Home Assistant that is unreachable holds nothing and the queue keeps running as though no interlock were configured. Sensors are their own thing rather than a smart plug with a wider entity filter — a plug carries auto-on, schedules, power alerts and "controls printer power", and the printer card's power button would have happily tried to switch a door contact. One backend poller reads every bound entity every 15 seconds and the cards serve that cached reading, so the cost to Home Assistant does not grow with the number of printers on screen or browser tabs open. Off by default in every respect: a newly bound sensor is display-only until you give it an alert condition, and both the notification and the interlock are separate opt-ins on top of that.
|
||||
|
||||
@@ -159,6 +159,43 @@ describe('Layout', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('finance nav item', () => {
|
||||
it('stays out of the sidebar while billing is off', async () => {
|
||||
// billing_enabled defaults to false and the Finance page has nothing to
|
||||
// show without it, so the entry must not be there at all.
|
||||
render(<Layout />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('aside a[href="/stats"]')).toBeInTheDocument();
|
||||
});
|
||||
expect(document.querySelector('aside a[href="/finance"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('appears between Statistics and Settings once billing is on', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/settings/', () =>
|
||||
HttpResponse.json({
|
||||
check_updates: false,
|
||||
check_printer_firmware: false,
|
||||
auto_archive: true,
|
||||
billing_enabled: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<Layout />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('aside a[href="/finance"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const sidebar = document.querySelector('aside');
|
||||
const hrefs = Array.from(sidebar?.querySelectorAll('a[href]') ?? []).map((a) => a.getAttribute('href'));
|
||||
expect(hrefs.indexOf('/finance')).toBeGreaterThan(hrefs.indexOf('/stats'));
|
||||
expect(hrefs.indexOf('/finance')).toBeLessThan(hrefs.indexOf('/settings'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('version display', () => {
|
||||
it('shows version info', async () => {
|
||||
render(<Layout />);
|
||||
|
||||
@@ -368,7 +368,7 @@ describe('SettingsPage', () => {
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
SIDEBAR_ORDER_KEY,
|
||||
JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'finance', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'notifications', 'settings']),
|
||||
JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'finance', 'notifications', 'settings']),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -410,7 +410,7 @@ describe('SettingsPage', () => {
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
SIDEBAR_ORDER_KEY,
|
||||
JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'finance', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'notifications', 'settings', 'ext-7']),
|
||||
JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'finance', 'notifications', 'settings', 'ext-7']),
|
||||
);
|
||||
|
||||
const settingsRow = screen.getAllByText('Settings')
|
||||
@@ -476,12 +476,12 @@ describe('SettingsPage', () => {
|
||||
'archives',
|
||||
'queue',
|
||||
'projects',
|
||||
'finance',
|
||||
'files',
|
||||
'makerworld',
|
||||
'profiles',
|
||||
'maintenance',
|
||||
'stats',
|
||||
'finance',
|
||||
'notifications',
|
||||
'settings',
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Wallet, type LucideIcon } from 'lucide-react';
|
||||
import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Receipt, type LucideIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
|
||||
@@ -43,12 +43,14 @@ export const defaultNavItems: NavItem[] = [
|
||||
{ id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
|
||||
{ id: 'queue', to: '/queue', icon: ListOrdered, labelKey: 'nav.queue' },
|
||||
{ id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
|
||||
{ id: 'finance', to: '/finance', icon: Wallet, labelKey: 'nav.finance' },
|
||||
{ id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
|
||||
{ id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' },
|
||||
{ id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
|
||||
{ id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
|
||||
{ id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
|
||||
// Opt-in feature: gated in isHidden() on the billing_enabled setting, so the
|
||||
// entry stays out of the sidebar entirely until an admin turns billing on.
|
||||
{ id: 'finance', to: '/finance', icon: Receipt, labelKey: 'nav.finance' },
|
||||
// User-account feature: gated in isHidden() on advanced auth + user_notifications
|
||||
// + the notifications:user_email permission. Kept adjacent to Settings
|
||||
// intentionally. Do not drop this entry — without it the /notifications page
|
||||
@@ -336,6 +338,11 @@ export function Layout() {
|
||||
}
|
||||
// notifications nav item also requires advanced auth to be enabled and user_notifications_enabled setting
|
||||
if (id === 'notifications' && (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || (settings?.user_notifications_enabled === false))) return true;
|
||||
// Finance is off by default and the page is meaningless without it, so it
|
||||
// stays hidden until billing is explicitly on. Tested for `true` rather
|
||||
// than `!== false` on purpose: settings are undefined on the first render,
|
||||
// and a nav entry that appears and then vanishes reads as a glitch.
|
||||
if (id === 'finance' && settings?.billing_enabled !== true) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -26,8 +26,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-CDkM7wuh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ud1tvgv1.css">
|
||||
<script type="module" crossorigin src="/assets/index-WBZDnFeY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DZYWm6I1.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user