Skip to content

Commit 147ef56

Browse files
committed
Improve configuration input display and config comparison
1 parent 43dd95f commit 147ef56

8 files changed

Lines changed: 269 additions & 48 deletions

File tree

‎web/configurator.template.html‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,21 +83,24 @@ <h1 class="pc-title">pg-configurator<small>Explainable, hardware-aware PostgreSQ
8383
const applyTheme = () => {
8484
document.documentElement.setAttribute('data-pv-theme', toggle.checked ? 'light' : 'dark');
8585
};
86-
toggle.addEventListener('change', () => {
87-
applyTheme();
86+
// Embedded hosts own the theme; ignore this page's standalone preference.
87+
if (!document.documentElement.hasAttribute('data-pc-embedded')) {
88+
toggle.addEventListener('change', () => {
89+
applyTheme();
90+
try {
91+
localStorage.setItem('pgc-theme', toggle.checked ? 'light' : 'dark');
92+
} catch {
93+
// Storage can be unavailable; the toggle still works for this visit.
94+
}
95+
});
8896
try {
89-
localStorage.setItem('pgc-theme', toggle.checked ? 'light' : 'dark');
97+
const stored = localStorage.getItem('pgc-theme');
98+
if (stored) toggle.checked = stored === 'light';
9099
} catch {
91-
// Storage can be unavailable; the toggle still works for this visit.
100+
// ignored, see above
92101
}
93-
});
94-
try {
95-
const stored = localStorage.getItem('pgc-theme');
96-
if (stored) toggle.checked = stored === 'light';
97-
} catch {
98-
// ignored, see above
102+
applyTheme();
99103
}
100-
applyTheme();
101104
</script>
102105
</body>
103106
</html>

‎web/css/pgc.css‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@ body {
169169
user-select: none;
170170
}
171171

172+
[data-pc-embedded] .pc-theme-toggle {
173+
display: none;
174+
}
175+
172176
.pc-theme-toggle input {
173177
appearance: none;
174178
position: relative;
@@ -465,7 +469,7 @@ body {
465469
}
466470

467471
.pc-readout {
468-
width: 8ch;
472+
width: calc(8ch + 22px);
469473
flex: 0 0 auto;
470474
padding: 5px 10px;
471475
border: 1px solid var(--vw-border);

‎web/src/config-diff.js‎

Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
*/
2323

2424
import { numericValueInSettingUnits } from './make-conf.js';
25+
import { sizeTo, SYS_PG } from './units.js';
2526

2627
const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.]*$/;
2728
const CONF_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_.]*)\s*(?:=\s*|\s+)(.*)$/;
@@ -85,33 +86,50 @@ export function parseConfText(text) {
8586
return { entries, skipped };
8687
}
8788

88-
/** One delimited line into cells; double quotes wrap a cell, `""` is one quote. */
89+
/** Logical CSV records: embedded newlines belong to a quoted value. */
90+
function delimitedRecords(text) {
91+
const records = [];
92+
let start = 0;
93+
let quoted = false;
94+
for (let i = 0; i < text.length; i += 1) {
95+
if (text[i] === '"') {
96+
if (quoted && text[i + 1] === '"') i += 1;
97+
else quoted = !quoted;
98+
} else if ((text[i] === '\n' || text[i] === '\r') && !quoted) {
99+
records.push(text.slice(start, i));
100+
if (text[i] === '\r' && text[i + 1] === '\n') i += 1;
101+
start = i + 1;
102+
}
103+
}
104+
if (start < text.length) records.push(text.slice(start));
105+
return records;
106+
}
107+
108+
/** One logical record into cells; quoted whitespace is part of the value. */
89109
function splitDelimited(line, delimiter) {
90110
const cells = [];
91111
let cell = '';
92112
let quoted = false;
113+
let wasQuoted = false;
114+
const finish = () => {
115+
cells.push(wasQuoted ? cell : cell.trim());
116+
cell = ''; wasQuoted = false;
117+
};
93118
for (let index = 0; index < line.length; index += 1) {
94119
const char = line[index];
95120
if (quoted) {
96121
if (char === '"' && line[index + 1] === '"') {
97-
cell += '"';
98-
index += 1;
99-
} else if (char === '"') {
100-
quoted = false;
101-
} else {
102-
cell += char;
103-
}
122+
cell += '"'; index += 1;
123+
} else if (char === '"') quoted = false;
124+
else cell += char;
104125
} else if (char === '"') {
105-
quoted = true;
106-
} else if (char === delimiter) {
107-
cells.push(cell);
108-
cell = '';
109-
} else {
110-
cell += char;
111-
}
126+
quoted = true; wasQuoted = true;
127+
} else if (char === delimiter) finish();
128+
else cell += char;
112129
}
113-
cells.push(cell);
114-
return cells.map((item) => item.trim());
130+
if (quoted) return null; // An incomplete record is not a partial setting.
131+
finish();
132+
return cells;
115133
}
116134

117135
/** A pg_settings export, with or without a header row, into name → value. */
@@ -121,9 +139,10 @@ export function parseDelimitedText(text, delimiter) {
121139
let header = false;
122140
let columns = { name: 0, value: 1, unit: null };
123141
let first = true;
124-
for (const rawLine of text.split(/\r?\n/)) {
142+
for (const rawLine of delimitedRecords(text)) {
125143
if (!rawLine.trim()) continue;
126144
const cells = splitDelimited(rawLine, delimiter);
145+
if (cells === null) { skipped += 1; continue; }
127146
if (first) {
128147
first = false;
129148
const lowered = cells.map((cell) => cell.toLowerCase());
@@ -156,12 +175,21 @@ export function parseDelimitedText(text, delimiter) {
156175
export function detectFormat(text) {
157176
const lines = text.split(/\r?\n/).filter((line) => line.trim() && !line.trim().startsWith('#'));
158177
if (!lines.length) return { format: 'empty', delimiter: null };
178+
// A named CSV header is stronger evidence than the contents of multiline
179+
// values, which may themselves contain assignments or other delimiters.
180+
for (const delimiter of DELIMITERS) {
181+
const header = splitDelimited(lines[0], delimiter)?.map(cell => cell.toLowerCase());
182+
if (header?.includes('name') && HEADER_VALUE_COLUMNS.some(name => header.includes(name))) {
183+
return { format: 'csv', delimiter };
184+
}
185+
}
159186
const assignments = lines.filter((line) => /^\s*[A-Za-z_][A-Za-z0-9_.]*\s*=\s*\S/.test(line));
160187
if (assignments.length * 2 >= lines.length) return { format: 'conf', delimiter: null };
161188
let best = null;
189+
const records = delimitedRecords(text).filter(record => record.trim());
162190
for (const delimiter of DELIMITERS) {
163-
const count = lines.filter((line) => line.includes(delimiter)).length;
164-
if (count * 2 >= lines.length && (best === null || count > best.count)) {
191+
const count = records.filter(record => (splitDelimited(record, delimiter)?.length ?? 0) > 1).length;
192+
if (count * 2 >= records.length && (best === null || count > best.count)) {
165193
best = { delimiter, count };
166194
}
167195
}
@@ -241,6 +269,36 @@ export function normalizeSettingValue(rawValue, sourceUnit, metadata) {
241269
}
242270
}
243271

272+
/** Display current values in the calculated configuration's PostgreSQL units. */
273+
export function formatCurrentSettingValue(rawValue, sourceUnit, metadata) {
274+
const raw = String(rawValue ?? '');
275+
if (['string', 'enum', 'bool'].includes(metadata?.vartype)) return raw;
276+
const match = NUMERIC_PATTERN.exec(stripSingleQuotes(raw.trim()));
277+
if (match === null) return raw;
278+
const amount = Number(match[1]);
279+
const unit = match[2] || sourceUnit || metadata?.unit || '';
280+
if (!Number.isFinite(amount) || !unit) return raw;
281+
// Disabled, unlimited and automatic values are not negative durations/sizes.
282+
if (amount <= 0) return String(amount);
283+
try {
284+
if (/^(us|ms|s|min|h|d)$/.test(unit)) {
285+
for (const suffix of ['d', 'h', 'min', 's', 'ms', 'us']) {
286+
const value = numericValueInSettingUnits(amount, unit, suffix);
287+
if (value >= 1 && Number.isInteger(value)) return `${value}${suffix}`;
288+
}
289+
return `${match[1]}${unit}`;
290+
}
291+
const bytes = numericValueInSettingUnits(amount, unit, 'B');
292+
// Reuse the engine's unit selection, but do not truncate collected values
293+
// as sizeTo does when constructing an integer-valued candidate setting.
294+
const suffix = sizeTo(bytes, SYS_PG).match(/[A-Za-z]+$/)?.[0] || 'B';
295+
return `${numericValueInSettingUnits(bytes, 'B', suffix)}${suffix}`;
296+
} catch {
297+
// Unknown units or non-numeric/redacted values retain the original text.
298+
return raw;
299+
}
300+
}
301+
244302
/**
245303
* The settings this run calculated that the pasted configuration sets to a
246304
* different value. `matching` counts agreement, `missing` the calculated
@@ -267,7 +325,7 @@ export function diffConfigurations(parameters, entries, metadata) {
267325
rows.push({
268326
name,
269327
calculated: detail.value,
270-
yours: entry.unit ? `${entry.value} (${entry.unit})` : entry.value,
328+
yours: formatCurrentSettingValue(entry.value, entry.unit, meta),
271329
apply_mode: detail.apply_mode,
272330
context: detail.context,
273331
source: detail.source,

‎web/src/make-conf.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,8 @@ const SIZE_FACTORS = {
310310
TB: 1024 ** 4,
311311
};
312312

313-
const TIME_FACTORS = { ms: 0.001, s: 1, min: 60, h: 3600, d: 86400 };
313+
// Integer microsecond factors avoid fractional arithmetic for exact conversions.
314+
const TIME_FACTORS = { us: 1, ms: 1000, s: 1_000_000, min: 60_000_000, h: 3_600_000_000, d: 86_400_000_000 };
314315

315316
export function numericValueInSettingUnits(amount, sourceUnit, targetUnit) {
316317
if (!sourceUnit) return amount;

‎web/src/render.js‎

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
} from './make-conf.js';
2121
import { diffConfigurations, parseUserConfig } from './config-diff.js';
2222
import { createEnums } from './configurator.js';
23-
import { numberOf, PyNumber, pyStr, sizeTo, SYS_IEC } from './units.js';
23+
import { numberOf, PyNumber, pyStr, sizeFrom, sizeTo, SYS_IEC } from './units.js';
2424

2525
/**
2626
* The input groups, in the order someone actually settles them.
@@ -536,7 +536,15 @@ export class ConfiguratorPage {
536536
item.value = choice;
537537
input.append(item);
538538
}
539-
input.value = String(this.values[option.dest] ?? localChoices[0]);
539+
const current = this.values[option.dest] ?? localChoices[0];
540+
const displayed = formatInputValue(option.dest, current);
541+
if (!localChoices.includes(displayed)) {
542+
// Collected initdb sizes can be valid without being a common preset.
543+
const item = el('option', null, displayed);
544+
item.value = displayed;
545+
input.append(item);
546+
}
547+
input.value = displayed;
540548
} else if (option.choices !== null) {
541549
input = el('select');
542550
for (const choice of option.choices) {
@@ -684,7 +692,16 @@ export class ConfiguratorPage {
684692
};
685693

686694
const write = (value) => {
687-
readout.value = value === null ? '' : String(value);
695+
readout.value = formatInputValue(option.dest, value);
696+
readout.removeAttribute('data-pc-tip');
697+
if (spec.unit && value !== null && value !== '') {
698+
try {
699+
const bytes = numberOf(sizeFrom(value, SYS_IEC));
700+
if (Number.isFinite(bytes)) readout.setAttribute('data-pc-tip', `Exact value used for calculation: ${bytes} B`);
701+
} catch {
702+
// Invalid input is explained by the calculation error, not a size tip.
703+
}
704+
}
688705
const numeric = sliderPosition(value, spec);
689706
if (numeric !== null) range.value = String(rangePosition(numeric, spec));
690707
showUnset();
@@ -701,6 +718,7 @@ export class ConfiguratorPage {
701718
readout.addEventListener('input', () => {
702719
const raw = readout.value.trim();
703720
this.values[option.dest] = raw === '' && spec.optional ? null : coerceReadout(raw, option);
721+
readout.removeAttribute('data-pc-tip');
704722
const numeric = sliderPosition(this.values[option.dest], spec);
705723
if (numeric !== null) range.value = String(rangePosition(numeric, spec));
706724
this.calculate();
@@ -1170,8 +1188,19 @@ export class ConfiguratorPage {
11701188
for (const item of rows) {
11711189
const row = el('tr');
11721190
row.append(el('td', null, item.name));
1173-
row.append(el('td', null, item.calculated));
1174-
row.append(el('td', 'pc-diff-yours', item.yours));
1191+
for (const [value, className] of [[item.calculated, null], [item.yours, 'pc-diff-yours']]) {
1192+
const cell = el('td', className);
1193+
if (item.name === 'shared_preload_libraries') {
1194+
const libraries = String(value).split(',');
1195+
libraries.forEach((library, index) => {
1196+
if (index > 0) cell.append(document.createElement('br'));
1197+
cell.append(document.createTextNode(library + (index < libraries.length - 1 ? ',' : '')));
1198+
});
1199+
} else {
1200+
cell.textContent = value;
1201+
}
1202+
row.append(cell);
1203+
}
11751204
const apply = el('td');
11761205
apply.append(el('span', `pc-apply pc-apply-${item.apply_mode}`, item.apply_mode));
11771206
row.append(apply);
@@ -1279,6 +1308,22 @@ const round2 = (value) => Math.round(value * 100) / 100;
12791308

12801309
const UNIT_BYTES = { Ki: 1024, Mi: 1024 ** 2, Gi: 1024 ** 3, Ti: 1024 ** 4 };
12811310

1311+
/** Compact size readouts; the model retains the exact collected input. */
1312+
export function formatInputValue(dest, value) {
1313+
if (value === null || value === undefined) return '';
1314+
const raw = String(value);
1315+
if (raw === '' || (!SLIDERS[dest]?.unit && dest !== 'wal_segment_size')) return raw;
1316+
try {
1317+
const bytes = numberOf(sizeFrom(value, SYS_IEC));
1318+
if (!Number.isFinite(bytes) || bytes < 0) return raw;
1319+
const [factor, unit] = SYS_IEC.find(([factor]) => bytes >= factor) ?? [1, ''];
1320+
return `${Number((bytes / factor).toFixed(2))}${unit || 'B'}`;
1321+
} catch {
1322+
// Preserve incomplete/invalid input so validation can explain it.
1323+
return raw;
1324+
}
1325+
}
1326+
12821327
/**
12831328
* Render a size in the largest IEC unit that divides it exactly.
12841329
*
@@ -1316,10 +1361,12 @@ function sliderPosition(value, spec) {
13161361
const numeric = Number(value);
13171362
return Number.isFinite(numeric) ? numeric : null;
13181363
}
1319-
const match = /^\s*(\d+(?:\.\d+)?)\s*(Ki|Mi|Gi|Ti)?\s*$/.exec(String(value));
1320-
if (match === null) return null;
1321-
const bytes = Number(match[1]) * (UNIT_BYTES[match[2] ?? spec.unit] ?? 1);
1322-
return bytes / UNIT_BYTES[spec.unit];
1364+
try {
1365+
const bytes = numberOf(sizeFrom(value, SYS_IEC));
1366+
return Number.isFinite(bytes) ? bytes / UNIT_BYTES[spec.unit] : null;
1367+
} catch {
1368+
return null;
1369+
}
13231370
}
13241371

13251372
/** Render a slider position in the syntax the option expects. */

0 commit comments

Comments
 (0)