initial commit
This commit is contained in:
@@ -0,0 +1,601 @@
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f4f1ec;
|
||||
--fg: #1a1d22;
|
||||
--muted: #595f68;
|
||||
--dim: #9299a2;
|
||||
--border: rgba(26, 29, 34, 0.12);
|
||||
--border-soft: rgba(26, 29, 34, 0.06);
|
||||
--field-bg: rgba(26, 29, 34, 0.03);
|
||||
--field-border: rgba(26, 29, 34, 0.18);
|
||||
--btn-bg: rgba(26, 29, 34, 0.05);
|
||||
--btn-bg-hover: rgba(26, 29, 34, 0.09);
|
||||
--btn-border: rgba(26, 29, 34, 0.18);
|
||||
--header-border: rgba(26, 29, 34, 0.12);
|
||||
--link: #2c5a7a;
|
||||
--accent: #2c5a7a;
|
||||
--nav-bg: rgba(244, 241, 236, 0.6);
|
||||
--bg-panel: rgba(255, 253, 250, 0.72);
|
||||
--notice-fg: #2a6e2a;
|
||||
--notice-bg: rgba(42, 110, 42, 0.06);
|
||||
--notice-border: rgba(42, 110, 42, 0.25);
|
||||
--error-fg: #a03030;
|
||||
--error-bg: rgba(160, 48, 48, 0.06);
|
||||
--error-border: rgba(160, 48, 48, 0.25);
|
||||
--toggle-bg: linear-gradient(135deg, #07070d 0%, #64dcff 100%);
|
||||
--toggle-border: rgba(26, 29, 34, 0.2);
|
||||
--toggle-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="tokyo"] {
|
||||
color-scheme: dark;
|
||||
--bg: #07070d;
|
||||
--fg: #e8f4ff;
|
||||
--muted: #a3b3cc;
|
||||
--dim: #7886a0;
|
||||
--border: rgba(100, 220, 255, 0.12);
|
||||
--border-soft: rgba(100, 220, 255, 0.06);
|
||||
--field-bg: rgba(100, 220, 255, 0.04);
|
||||
--field-border: rgba(100, 220, 255, 0.15);
|
||||
--btn-bg: rgba(100, 220, 255, 0.06);
|
||||
--btn-bg-hover: rgba(100, 220, 255, 0.1);
|
||||
--btn-border: rgba(100, 220, 255, 0.18);
|
||||
--header-border: rgba(100, 220, 255, 0.12);
|
||||
--link: #64dcff;
|
||||
--accent: #64dcff;
|
||||
--nav-bg: rgba(7, 7, 13, 0.65);
|
||||
--bg-panel: rgba(15, 16, 28, 0.72);
|
||||
--notice-fg: #8fe08f;
|
||||
--notice-bg: rgba(143, 224, 143, 0.06);
|
||||
--notice-border: rgba(143, 224, 143, 0.25);
|
||||
--error-fg: #ff9b9b;
|
||||
--error-bg: rgba(255, 155, 155, 0.06);
|
||||
--error-border: rgba(255, 155, 155, 0.2);
|
||||
--toggle-bg: #f4f1ec;
|
||||
--toggle-border: rgba(100, 220, 255, 0.4);
|
||||
--toggle-shadow: 0 0 10px rgba(100, 220, 255, 0.5), 0 0 25px rgba(100, 220, 255, 0.3);
|
||||
}
|
||||
|
||||
html, body { max-width: 100%; overflow-x: hidden; }
|
||||
body {
|
||||
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.55;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
header {
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
padding: 1rem 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
background: var(--nav-bg);
|
||||
backdrop-filter: blur(12px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(120%);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
main {
|
||||
overflow-wrap: break-word;
|
||||
padding: 16px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workbench {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(520px, 0.9fr) minmax(460px, 1.1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.interaction-pane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-weight: 400;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.back-link:hover { color: var(--fg); border-color: var(--muted); background: var(--bg-panel); }
|
||||
|
||||
.brand {
|
||||
justify-self: center;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand-sep { font-weight: 300; color: var(--dim); }
|
||||
.brand-sub {
|
||||
font-weight: 400;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-theme="tokyo"] .brand { color: #e8f4ff; text-shadow: 0 0 8px rgba(100, 220, 255, 0.3); }
|
||||
|
||||
footer {
|
||||
padding: 0.9rem 1.5rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.05em;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
background: var(--nav-bg);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
footer a, a { color: var(--link); }
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
h1 { margin: 0 0 0.9rem; font-size: clamp(1.35rem, 4vw, 2rem); }
|
||||
h2 { margin: 0 0 0.9rem; font-size: 1.1rem; }
|
||||
h3 { margin: 0 0 0.4rem; font-size: 0.9rem; }
|
||||
p { margin: 0 0 0.8rem; }
|
||||
code { font-family: inherit; font-size: 0.86em; }
|
||||
.muted { color: var(--muted); font-size: 0.78rem; }
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inventory-shell,
|
||||
.control-deck,
|
||||
.cdc-pane {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--field-bg);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.inventory-shell {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.control-deck {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.inventory-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.inventory-slot {
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-panel);
|
||||
color: var(--fg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
min-height: 118px;
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.inventory-slot:hover { background: var(--btn-bg-hover); }
|
||||
.inventory-slot.ok { border-color: var(--notice-border); }
|
||||
.inventory-slot.low { border-color: rgba(185, 126, 0, 0.35); }
|
||||
.inventory-slot.empty { border-color: var(--error-border); }
|
||||
.inventory-slot.selected {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.inventory-slot.changed {
|
||||
background: var(--notice-bg);
|
||||
border-color: var(--notice-border);
|
||||
}
|
||||
.inventory-slot strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.inventory-slot .quantity {
|
||||
display: inline-block;
|
||||
min-width: 1.8ch;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.slot-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.create-slot {
|
||||
border-style: dashed;
|
||||
color: var(--muted);
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.create-slot strong {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid var(--border);
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
min-width: 0;
|
||||
border-radius: 3px;
|
||||
background: var(--field-bg);
|
||||
}
|
||||
legend {
|
||||
font-weight: 500;
|
||||
padding: 0 6px;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.selected-summary {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-panel);
|
||||
padding: 0.9rem 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 130px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.selected-summary.create-mode {
|
||||
border-style: dashed;
|
||||
}
|
||||
.selected-summary strong {
|
||||
display: block;
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
.summary-kicker {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.action-buttons {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
.action-buttons.two-up {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.selected-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.selected-actions fieldset,
|
||||
.create-actions fieldset {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.primary-action-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.primary-stock-action {
|
||||
min-height: 92px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 0.2rem;
|
||||
background: var(--bg-panel);
|
||||
border-color: var(--field-border);
|
||||
}
|
||||
.primary-stock-action span {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.primary-stock-action strong {
|
||||
font-size: 1.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.more-actions {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--field-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.more-actions > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
padding: 0.65rem 0.8rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.more-actions > summary::-webkit-details-marker { display: none; }
|
||||
.more-actions > summary::after {
|
||||
content: '+';
|
||||
float: right;
|
||||
color: var(--dim);
|
||||
}
|
||||
.more-actions[open] > summary {
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
}
|
||||
.more-actions[open] > summary::after { content: '-'; }
|
||||
.more-actions-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 170px;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.compact-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.compact-form-grid label:nth-child(3),
|
||||
.compact-form-grid label:nth-child(4) {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.create-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.create-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(0, 1fr) 100px;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.create-form-grid label:nth-child(2) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.create-form-grid label:nth-child(4) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.create-form-grid label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
select, input[type=text], input[type=number], input:not([type]) {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border: 1px solid var(--field-border);
|
||||
border-radius: 3px;
|
||||
background: var(--field-bg);
|
||||
color: var(--fg);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
[data-theme="tokyo"] select,
|
||||
[data-theme="tokyo"] option { background: #0f1018; color: #e8f4ff; }
|
||||
select:focus, input:focus { border-color: var(--accent); }
|
||||
label { display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 0.75rem; }
|
||||
label input, label select { margin-top: 0.25rem; }
|
||||
|
||||
.edit-actions button,
|
||||
.danger-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--btn-border);
|
||||
border-radius: 3px;
|
||||
background: var(--btn-bg);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
button:hover { background: var(--btn-bg-hover); }
|
||||
button.danger { color: var(--error-fg); border-color: var(--error-border); background: var(--error-bg); }
|
||||
.inline { display: inline-flex; gap: 6px; align-items: center; margin: 0; flex-wrap: wrap; }
|
||||
|
||||
.cdc-pane {
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
max-height: calc(100vh - 96px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.cdc-heading {
|
||||
padding: 1rem 1rem 0;
|
||||
}
|
||||
|
||||
.cdc-heading h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.event-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.detail-panel {
|
||||
grid-column: 1 / -1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--field-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.detail-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.detail-tab {
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 0.55rem 0.75rem;
|
||||
}
|
||||
.detail-tab.active {
|
||||
background: var(--btn-bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.detail-view { display: none; padding: 0.75rem; }
|
||||
.detail-view.active { display: block; }
|
||||
pre {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 3px;
|
||||
background: rgba(26, 29, 34, 0.04);
|
||||
margin: 0.8rem 0 0;
|
||||
padding: 0.85rem 0.95rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
[data-theme="tokyo"] pre { background: rgba(100, 220, 255, 0.04); }
|
||||
.json-output {
|
||||
max-height: 44vh;
|
||||
margin: 0;
|
||||
}
|
||||
.event-summary p { margin-bottom: 0.35rem; }
|
||||
|
||||
.audit-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.audit-row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 120px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 3px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.audit-row span { color: var(--muted); }
|
||||
.audit-row code {
|
||||
white-space: pre-wrap;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--toggle-border);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
z-index: 30;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: var(--toggle-shadow);
|
||||
background: var(--toggle-bg);
|
||||
}
|
||||
.theme-toggle:hover { transform: scale(1.08); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.workbench { grid-template-columns: 1fr; }
|
||||
.cdc-pane {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
.event-grid { grid-template-columns: 1fr; }
|
||||
.selected-actions,
|
||||
.more-actions-panel,
|
||||
.create-form-grid,
|
||||
.compact-form-grid { grid-template-columns: 1fr; }
|
||||
.create-actions { grid-column: auto; }
|
||||
.primary-stock-action { min-height: 72px; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
header {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: start;
|
||||
padding: 0.7rem 0.9rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.brand { justify-self: start; font-size: 0.9rem; }
|
||||
main { padding: 12px; }
|
||||
footer { font-size: 0.62rem; padding: 0.7rem 0.9rem; }
|
||||
.theme-toggle { width: 20px; height: 20px; bottom: 1rem; right: 0.9rem; }
|
||||
.inventory-grid,
|
||||
.primary-action-row,
|
||||
.action-buttons.two-up { grid-template-columns: 1fr; }
|
||||
.inventory-slot { min-height: 98px; }
|
||||
.selected-summary { grid-template-columns: 1fr; gap: 4px; }
|
||||
.section-heading { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||
.audit-row { grid-template-columns: 1fr; gap: 2px; }
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
(() => {
|
||||
const grid = document.getElementById('inventory-grid');
|
||||
const note = document.getElementById('inventory-note');
|
||||
const selected = document.getElementById('selected-item');
|
||||
const createForm = document.getElementById('create-form');
|
||||
const editForm = document.getElementById('edit-form');
|
||||
const sellButton = document.getElementById('sell-one');
|
||||
const restockButton = document.getElementById('restock-five');
|
||||
const deleteButton = document.getElementById('delete-item');
|
||||
const commandJSON = document.getElementById('command-json');
|
||||
const rawJSON = document.getElementById('raw-json');
|
||||
const eventSummary = document.getElementById('event-summary');
|
||||
const auditList = document.getElementById('audit-list');
|
||||
const slotNote = document.getElementById('slot-note');
|
||||
const selectedSummary = document.getElementById('selected-summary');
|
||||
const selectedName = document.getElementById('selected-name');
|
||||
const selectedMeta = document.getElementById('selected-meta');
|
||||
const moreActions = document.querySelector('.more-actions');
|
||||
const detailTabs = Array.from(document.querySelectorAll('[data-detail-tab]'));
|
||||
const detailViews = Array.from(document.querySelectorAll('[data-detail-view]'));
|
||||
|
||||
const maxInventoryItems = 4;
|
||||
let inventory = [];
|
||||
let lastChangedID = null;
|
||||
let preferredSelectedID = '';
|
||||
let mode = 'product';
|
||||
|
||||
function pretty(value) {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function setCommand(payload) {
|
||||
commandJSON.textContent = pretty(payload);
|
||||
}
|
||||
|
||||
function stockClass(quantity) {
|
||||
if (quantity <= 0) return 'empty';
|
||||
if (quantity <= 5) return 'low';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
function stockLabel(quantity) {
|
||||
if (quantity <= 0) return 'empty';
|
||||
if (quantity <= 5) return 'low stock';
|
||||
return 'in stock';
|
||||
}
|
||||
|
||||
function renderInventory(items) {
|
||||
const previousSelectedID = selected.value;
|
||||
inventory = Array.isArray(items) ? items : [];
|
||||
grid.innerHTML = '';
|
||||
selected.innerHTML = '';
|
||||
|
||||
if (!inventory.length) {
|
||||
selected.innerHTML = '<option value="">no rows</option>';
|
||||
for (let i = 0; i < maxInventoryItems; i += 1) renderEmptySlot();
|
||||
mode = 'create';
|
||||
updateActionPane();
|
||||
note.textContent = `0 / ${maxInventoryItems} products`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of inventory) {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = `inventory-slot ${stockClass(item.quantity)}`;
|
||||
row.dataset.id = item.id;
|
||||
row.innerHTML = `
|
||||
<span class="slot-top"><span>${escapeHTML(item.sku)}</span><span>${stockLabel(item.quantity)}</span></span>
|
||||
<strong>${escapeHTML(item.name)}</strong>
|
||||
<span><span class="quantity">${item.quantity}</span> / ${escapeHTML(item.location)}</span>
|
||||
`;
|
||||
row.addEventListener('click', () => {
|
||||
mode = 'product';
|
||||
selected.value = String(item.id);
|
||||
if (moreActions) moreActions.open = false;
|
||||
updateActionPane();
|
||||
});
|
||||
grid.appendChild(row);
|
||||
|
||||
const option = document.createElement('option');
|
||||
option.value = item.id;
|
||||
option.textContent = `${item.sku} / ${item.name}`;
|
||||
selected.appendChild(option);
|
||||
}
|
||||
|
||||
for (let i = inventory.length; i < maxInventoryItems; i += 1) {
|
||||
renderEmptySlot();
|
||||
}
|
||||
|
||||
if (preferredSelectedID && inventory.some((item) => String(item.id) === preferredSelectedID)) {
|
||||
selected.value = preferredSelectedID;
|
||||
preferredSelectedID = '';
|
||||
mode = 'product';
|
||||
} else if (previousSelectedID && inventory.some((item) => String(item.id) === previousSelectedID)) {
|
||||
selected.value = previousSelectedID;
|
||||
mode = 'product';
|
||||
} else if (mode !== 'create') {
|
||||
selected.value = String(inventory[0].id);
|
||||
mode = 'product';
|
||||
}
|
||||
|
||||
updateActionPane();
|
||||
note.textContent = `${inventory.length} / ${maxInventoryItems} products`;
|
||||
}
|
||||
|
||||
function renderEmptySlot() {
|
||||
const empty = document.createElement('button');
|
||||
empty.type = 'button';
|
||||
empty.className = 'inventory-slot create-slot';
|
||||
empty.innerHTML = '<strong>+ Create product</strong><span>empty slot</span>';
|
||||
empty.addEventListener('click', () => {
|
||||
mode = 'create';
|
||||
selected.value = '';
|
||||
if (moreActions) moreActions.open = false;
|
||||
updateActionPane();
|
||||
});
|
||||
grid.appendChild(empty);
|
||||
}
|
||||
|
||||
function updateActionPane() {
|
||||
const item = inventory.find((row) => String(row.id) === selected.value);
|
||||
const hasSpace = inventory.length < maxInventoryItems;
|
||||
const createMode = mode === 'create';
|
||||
|
||||
document.querySelectorAll('.inventory-slot[data-id]').forEach((row) => {
|
||||
row.classList.toggle('selected', !createMode && row.dataset.id === selected.value);
|
||||
row.classList.toggle('changed', row.dataset.id === String(lastChangedID || ''));
|
||||
});
|
||||
document.querySelectorAll('.create-slot').forEach((row) => {
|
||||
row.classList.toggle('selected', createMode);
|
||||
});
|
||||
|
||||
selectedSummary.classList.toggle('create-mode', createMode);
|
||||
editForm.hidden = createMode || !item;
|
||||
createForm.hidden = !createMode;
|
||||
|
||||
if (createMode) {
|
||||
slotNote.textContent = hasSpace ? `${inventory.length} / ${maxInventoryItems} products. Create fills the empty slot.` : `${maxInventoryItems} / ${maxInventoryItems} products. Delete one before creating.`;
|
||||
selectedName.textContent = hasSpace ? 'Empty slot' : 'Slots full';
|
||||
selectedMeta.textContent = hasSpace ? 'Create a product in the available slot.' : 'No empty inventory slots are available.';
|
||||
if (moreActions) moreActions.open = false;
|
||||
createForm.querySelector('button[type="submit"]').disabled = !hasSpace;
|
||||
return;
|
||||
}
|
||||
|
||||
createForm.querySelector('button[type="submit"]').disabled = !hasSpace;
|
||||
slotNote.textContent = `${inventory.length} / ${maxInventoryItems} products.`;
|
||||
|
||||
if (!item) {
|
||||
selectedName.textContent = 'No product selected';
|
||||
selectedMeta.textContent = 'Select a product slot.';
|
||||
return;
|
||||
}
|
||||
|
||||
selectedName.textContent = `${item.sku} / ${item.name}`;
|
||||
selectedMeta.textContent = `quantity ${item.quantity} · ${item.location}`;
|
||||
}
|
||||
|
||||
function selectCreatedOrChanged(event) {
|
||||
const id = changedInventoryID(event);
|
||||
if (!id) return;
|
||||
preferredSelectedID = String(id);
|
||||
mode = event.op === 'd' ? 'product' : 'product';
|
||||
}
|
||||
|
||||
async function loadInitialInventory() {
|
||||
const response = await fetch('/api/inventory', { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const rows = await response.json();
|
||||
renderInventory(rows);
|
||||
}
|
||||
|
||||
async function sendCommand(method, url, body, label) {
|
||||
setCommand({ command: label, method, url, body: body || null, note: 'UI waits for CDC event before changing inventory rows.' });
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json', Accept: 'application/json' } : { Accept: 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
setCommand({ ...payload, method, url, request: body || null, waiting_for: 'Debezium -> NATS JetStream -> SSE' });
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
function selectedID() {
|
||||
return selected.value;
|
||||
}
|
||||
|
||||
createForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(createForm);
|
||||
const ok = await sendCommand('POST', '/api/inventory', {
|
||||
sku: String(data.get('sku')).trim(),
|
||||
name: String(data.get('name')).trim(),
|
||||
quantity: Number(data.get('quantity')),
|
||||
location: String(data.get('location')).trim(),
|
||||
}, 'CREATE inventory item');
|
||||
if (ok) createForm.reset();
|
||||
});
|
||||
|
||||
editForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!selectedID()) return;
|
||||
const data = new FormData(editForm);
|
||||
const body = {};
|
||||
const name = String(data.get('name')).trim();
|
||||
const location = String(data.get('location')).trim();
|
||||
if (name) body.name = name;
|
||||
if (location) body.location = location;
|
||||
await sendCommand('PATCH', `/api/inventory/${selectedID()}`, body, 'UPDATE inventory name/location');
|
||||
editForm.elements.name.value = '';
|
||||
editForm.elements.location.value = '';
|
||||
if (moreActions) moreActions.open = false;
|
||||
});
|
||||
|
||||
sellButton.addEventListener('click', async () => {
|
||||
if (!selectedID()) return;
|
||||
await sendCommand('PATCH', `/api/inventory/${selectedID()}/sell`, null, 'UPDATE inventory quantity -1');
|
||||
});
|
||||
|
||||
restockButton.addEventListener('click', async () => {
|
||||
if (!selectedID()) return;
|
||||
await sendCommand('PATCH', `/api/inventory/${selectedID()}/restock`, { amount: 5 }, 'UPDATE inventory quantity +5');
|
||||
});
|
||||
|
||||
deleteButton.addEventListener('click', async () => {
|
||||
if (!selectedID()) return;
|
||||
await sendCommand('DELETE', `/api/inventory/${selectedID()}`, null, 'DELETE inventory item');
|
||||
if (moreActions) moreActions.open = false;
|
||||
});
|
||||
|
||||
function connectEvents() {
|
||||
const events = new EventSource('/events');
|
||||
|
||||
events.addEventListener('cdc', (message) => {
|
||||
const event = JSON.parse(message.data);
|
||||
lastChangedID = changedInventoryID(event);
|
||||
selectCreatedOrChanged(event);
|
||||
renderInventory(event.inventory);
|
||||
renderEvent(event);
|
||||
renderAudit(event);
|
||||
rawJSON.textContent = pretty(event.raw || {});
|
||||
});
|
||||
}
|
||||
|
||||
function changedInventoryID(event) {
|
||||
if (event.after && event.after.id) return event.after.id;
|
||||
if (event.before && event.before.id) return event.before.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderEvent(event) {
|
||||
const changes = diff(event.before, event.after);
|
||||
eventSummary.innerHTML = `
|
||||
<p><strong>CDC event received:</strong> ${escapeHTML(event.crud)}</p>
|
||||
<p><strong>source:</strong> ${escapeHTML(event.source)}</p>
|
||||
<pre><code>${escapeHTML(changes || 'No before/after field changes available.')}</code></pre>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAudit(event) {
|
||||
if (auditList.querySelector('.muted')) auditList.innerHTML = '';
|
||||
const row = document.createElement('article');
|
||||
row.className = 'audit-row';
|
||||
row.innerHTML = `
|
||||
<strong>${escapeHTML(event.crud)}</strong>
|
||||
<span>${escapeHTML(event.table)}</span>
|
||||
<code>${escapeHTML(diff(event.before, event.after) || 'snapshot/delete event')}</code>
|
||||
`;
|
||||
auditList.prepend(row);
|
||||
while (auditList.children.length > 12) auditList.lastElementChild.remove();
|
||||
}
|
||||
|
||||
function diff(before, after) {
|
||||
if (!before && !after) return '';
|
||||
if (!before && after) return Object.entries(after).map(([key, value]) => `${key}: ${value}`).join('\n');
|
||||
if (before && !after) return Object.entries(before).map(([key, value]) => `${key}: ${value} -> deleted`).join('\n');
|
||||
|
||||
const lines = [];
|
||||
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||||
for (const key of keys) {
|
||||
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
|
||||
lines.push(`${key}: ${before[key]} -> ${after[key]}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value).replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[char]));
|
||||
}
|
||||
|
||||
selected.addEventListener('change', () => {
|
||||
mode = 'product';
|
||||
if (moreActions) moreActions.open = false;
|
||||
updateActionPane();
|
||||
});
|
||||
detailTabs.forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
const name = tab.dataset.detailTab;
|
||||
detailTabs.forEach((item) => item.classList.toggle('active', item === tab));
|
||||
detailViews.forEach((view) => view.classList.toggle('active', view.dataset.detailView === name));
|
||||
});
|
||||
});
|
||||
|
||||
loadInitialInventory().catch((err) => {
|
||||
note.textContent = `Could not load inventory: ${err.message}`;
|
||||
});
|
||||
connectEvents();
|
||||
})();
|
||||
@@ -0,0 +1,23 @@
|
||||
(function () {
|
||||
function currentSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'tokyo' : 'light';
|
||||
}
|
||||
|
||||
var btn = document.getElementById('theme-toggle');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var current = document.documentElement.getAttribute('data-theme') || 'light';
|
||||
var next = current === 'light' ? 'tokyo' : 'light';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('poc-theme', next);
|
||||
window.dispatchEvent(new Event('theme-change'));
|
||||
});
|
||||
}
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
|
||||
if (!localStorage.getItem('poc-theme')) {
|
||||
document.documentElement.setAttribute('data-theme', currentSystemTheme());
|
||||
window.dispatchEvent(new Event('theme-change'));
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,138 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Live Inventory CDC Demo — FLÓ</title>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var stored = localStorage.getItem('poc-theme');
|
||||
var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'tokyo' : 'light');
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<a href="https://fló.fo" class="back-link">FLÓ.FO</a>
|
||||
<span class="brand">FLÓ <span class="brand-sep">/</span> <span class="brand-sub">CDC POC</span></span>
|
||||
</header>
|
||||
|
||||
<main id="main" class="workbench">
|
||||
<section class="interaction-pane">
|
||||
<section class="inventory-shell">
|
||||
<div class="section-heading">
|
||||
<h2>Inventory</h2>
|
||||
<span id="inventory-note" class="muted">Loading products...</span>
|
||||
</div>
|
||||
<div id="inventory-grid" class="inventory-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="control-deck">
|
||||
<select id="selected-item" name="id" class="visually-hidden" aria-label="Selected item"></select>
|
||||
|
||||
<section id="selected-summary" class="selected-summary">
|
||||
<span class="summary-kicker">Active product</span>
|
||||
<div>
|
||||
<strong id="selected-name">No product selected</strong>
|
||||
<span id="selected-meta" class="muted">Pick a product above.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form id="edit-form" class="action-form selected-actions">
|
||||
<div class="primary-action-row">
|
||||
<button type="button" id="sell-one" class="primary-stock-action">
|
||||
<span>Sell</span>
|
||||
<strong>1</strong>
|
||||
</button>
|
||||
<button type="button" id="restock-five" class="primary-stock-action">
|
||||
<span>Restock</span>
|
||||
<strong>5</strong>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details class="more-actions">
|
||||
<summary>More actions</summary>
|
||||
<div class="more-actions-panel">
|
||||
<fieldset class="edit-actions">
|
||||
<legend>Rename or move</legend>
|
||||
<div class="compact-form-grid">
|
||||
<label>Name <input name="name" placeholder="leave unchanged"></label>
|
||||
<label>Location <input name="location" placeholder="leave unchanged"></label>
|
||||
</div>
|
||||
<button type="submit">Save changes</button>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="danger-actions">
|
||||
<legend>Remove</legend>
|
||||
<button type="button" id="delete-item" class="danger">Delete product</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</details>
|
||||
</form>
|
||||
|
||||
<form id="create-form" class="action-form create-actions">
|
||||
<fieldset>
|
||||
<legend>Create product</legend>
|
||||
<p id="slot-note" class="muted">Create a product in the selected empty slot.</p>
|
||||
<div class="create-form-grid">
|
||||
<label>SKU <input name="sku" value="FL-440" required></label>
|
||||
<label>Name <input name="name" value="USB barcode scanner" required></label>
|
||||
<label>Quantity <input name="quantity" type="number" value="4" min="0" required></label>
|
||||
<label>Location <input name="location" value="Torshavn" required></label>
|
||||
<button type="submit">Create product</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="cdc-pane" aria-label="CDC trace">
|
||||
<div class="section-heading cdc-heading">
|
||||
<h2>CDC trace</h2>
|
||||
<span class="muted">Debezium -> NATS -> browser</span>
|
||||
</div>
|
||||
|
||||
<section class="event-grid">
|
||||
<fieldset>
|
||||
<legend>Command sent</legend>
|
||||
<pre><code id="command-json">No command sent yet.</code></pre>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>CDC event received</legend>
|
||||
<div id="event-summary" class="event-summary">Waiting for Debezium/NATS event...</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="detail-panel">
|
||||
<div class="detail-tabs" role="tablist" aria-label="CDC details">
|
||||
<button type="button" class="detail-tab active" data-detail-tab="raw">Raw JSON</button>
|
||||
<button type="button" class="detail-tab" data-detail-tab="audit">Audit trail</button>
|
||||
</div>
|
||||
<div class="detail-view active" data-detail-view="raw">
|
||||
<pre class="json-output"><code id="raw-json">{}</code></pre>
|
||||
</div>
|
||||
<div class="detail-view" data-detail-view="audit">
|
||||
<div id="audit-list" class="audit-list">
|
||||
<p class="muted">CDC events will appear here.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>© <script>document.write(new Date().getFullYear())</script> <a href="https://fló.fo">FLÓ.FO</a> | {{ .Version }}</span>
|
||||
</footer>
|
||||
|
||||
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
|
||||
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,631 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
//go:embed internal/web/templates/index.html
|
||||
var templateFS embed.FS
|
||||
|
||||
//go:embed internal/web/static
|
||||
var staticFS embed.FS
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
NATSURL string
|
||||
NATSSubject string
|
||||
}
|
||||
|
||||
const maxInventoryItems = 4
|
||||
|
||||
type InventoryItem struct {
|
||||
ID int `json:"id"`
|
||||
SKU string `json:"sku"`
|
||||
Name string `json:"name"`
|
||||
Quantity int `json:"quantity"`
|
||||
Location string `json:"location"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AcceptedResponse struct {
|
||||
Status string `json:"status"`
|
||||
CRUD string `json:"crud"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
type CDCEvent struct {
|
||||
CRUD string `json:"crud"`
|
||||
Op string `json:"op"`
|
||||
Table string `json:"table"`
|
||||
Before any `json:"before"`
|
||||
After any `json:"after"`
|
||||
Raw json.RawMessage `json:"raw"`
|
||||
Inventory []InventoryItem `json:"inventory"`
|
||||
Received time.Time `json:"received_at"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
db *pgxpool.Pool
|
||||
broker *Broker
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
type Broker struct {
|
||||
mu sync.Mutex
|
||||
clients map[chan CDCEvent]struct{}
|
||||
last *CDCEvent
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
db, err := connectDB(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("connect postgres: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
tpl, err := template.ParseFS(templateFS, "internal/web/templates/index.html")
|
||||
if err != nil {
|
||||
log.Fatalf("parse templates: %v", err)
|
||||
}
|
||||
|
||||
server := &Server{
|
||||
db: db,
|
||||
broker: NewBroker(),
|
||||
templates: tpl,
|
||||
}
|
||||
|
||||
go consumeNATS(ctx, cfg, db, server.broker)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
server.routes(mux)
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: logRequests(mux),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = httpServer.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
log.Printf("starting live inventory CDC demo on %s", cfg.HTTPAddr)
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("http server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() Config {
|
||||
return Config{
|
||||
HTTPAddr: env("HTTP_ADDR", ":8080"),
|
||||
DatabaseURL: env("DATABASE_URL", "postgres://postgres:postgres@localhost:5453/postgres?sslmode=disable"),
|
||||
NATSURL: env("NATS_URL", "nats://localhost:4222"),
|
||||
NATSSubject: env("NATS_SUBJECT", "postgres.public.inventory"),
|
||||
}
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func connectDB(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 30; attempt++ {
|
||||
db, err := pgxpool.New(ctx, databaseURL)
|
||||
if err == nil {
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
err = db.Ping(pingCtx)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return db, nil
|
||||
}
|
||||
db.Close()
|
||||
}
|
||||
lastErr = err
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * 250 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (s *Server) routes(mux *http.ServeMux) {
|
||||
static, err := fs.Sub(staticFS, "internal/web/static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static)))
|
||||
mux.HandleFunc("GET /", s.handleIndex)
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("GET /events", s.handleEvents)
|
||||
mux.HandleFunc("GET /api/inventory", s.handleListInventory)
|
||||
mux.HandleFunc("POST /api/inventory", s.handleCreateInventory)
|
||||
mux.HandleFunc("PATCH /api/inventory/{id}", s.handleUpdateInventory)
|
||||
mux.HandleFunc("PATCH /api/inventory/{id}/sell", s.handleSellInventory)
|
||||
mux.HandleFunc("PATCH /api/inventory/{id}/restock", s.handleRestockInventory)
|
||||
mux.HandleFunc("DELETE /api/inventory/{id}", s.handleDeleteInventory)
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
data := map[string]string{"Version": "dev"}
|
||||
if err := s.templates.ExecuteTemplate(w, "index.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
client := s.broker.Subscribe()
|
||||
defer s.broker.Unsubscribe(client)
|
||||
|
||||
if last := s.broker.Last(); last != nil {
|
||||
writeSSE(w, *last)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case event := <-client:
|
||||
writeSSE(w, event)
|
||||
flusher.Flush()
|
||||
case <-ticker.C:
|
||||
_, _ = fmt.Fprint(w, ": keepalive\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleListInventory(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := listInventory(r.Context(), s.db)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateInventory(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
SKU string `json:"sku"`
|
||||
Name string `json:"name"`
|
||||
Quantity int `json:"quantity"`
|
||||
Location string `json:"location"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
req.SKU = strings.TrimSpace(req.SKU)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Location = strings.TrimSpace(req.Location)
|
||||
if req.SKU == "" || req.Name == "" || req.Location == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("sku, name, and location are required"))
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(r.Context(), pgx.TxOptions{IsoLevel: pgx.Serializable})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
if _, err := tx.Exec(r.Context(), `lock table inventory in exclusive mode`); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := tx.QueryRow(r.Context(), `select count(*) from inventory`).Scan(&count); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if count >= maxInventoryItems {
|
||||
writeError(w, http.StatusConflict, fmt.Errorf("inventory is limited to %d products", maxInventoryItems))
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
insert into inventory (sku, name, quantity, location, updated_at)
|
||||
values ($1, $2, $3, $4, now())`,
|
||||
req.SKU, req.Name, req.Quantity, req.Location,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, AcceptedResponse{
|
||||
Status: "command accepted",
|
||||
CRUD: "CREATE",
|
||||
Command: fmt.Sprintf("CREATE inventory %s", req.SKU),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSellInventory(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_, err := s.db.Exec(r.Context(), `
|
||||
update inventory
|
||||
set quantity = greatest(quantity - 1, 0), updated_at = now()
|
||||
where id = $1`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, AcceptedResponse{
|
||||
Status: "command accepted",
|
||||
CRUD: "UPDATE",
|
||||
Command: "UPDATE inventory quantity -1",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRestockInventory(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Amount int `json:"amount"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Amount <= 0 {
|
||||
req.Amount = 5
|
||||
}
|
||||
_, err := s.db.Exec(r.Context(), `
|
||||
update inventory
|
||||
set quantity = quantity + $2, updated_at = now()
|
||||
where id = $1`,
|
||||
id, req.Amount,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, AcceptedResponse{
|
||||
Status: "command accepted",
|
||||
CRUD: "UPDATE",
|
||||
Command: fmt.Sprintf("UPDATE inventory quantity +%d", req.Amount),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateInventory(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Location *string `json:"location"`
|
||||
Quantity *int `json:"quantity"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(r.Context(), pgx.TxOptions{})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
current := InventoryItem{}
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
select id, sku, name, quantity, location, updated_at
|
||||
from inventory
|
||||
where id = $1
|
||||
for update`, id,
|
||||
).Scan(¤t.ID, ¤t.SKU, ¤t.Name, ¤t.Quantity, ¤t.Location, ¤t.UpdatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != nil && strings.TrimSpace(*req.Name) != "" {
|
||||
current.Name = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if req.Location != nil && strings.TrimSpace(*req.Location) != "" {
|
||||
current.Location = strings.TrimSpace(*req.Location)
|
||||
}
|
||||
if req.Quantity != nil {
|
||||
current.Quantity = *req.Quantity
|
||||
}
|
||||
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
update inventory
|
||||
set name = $2, quantity = $3, location = $4, updated_at = now()
|
||||
where id = $1`,
|
||||
current.ID, current.Name, current.Quantity, current.Location,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, AcceptedResponse{
|
||||
Status: "command accepted",
|
||||
CRUD: "UPDATE",
|
||||
Command: fmt.Sprintf("UPDATE inventory %s", current.SKU),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteInventory(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_, err := s.db.Exec(r.Context(), `delete from inventory where id = $1`, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, AcceptedResponse{
|
||||
Status: "command accepted",
|
||||
CRUD: "DELETE",
|
||||
Command: fmt.Sprintf("DELETE inventory id=%d", id),
|
||||
})
|
||||
}
|
||||
|
||||
func pathID(w http.ResponseWriter, r *http.Request) (int, bool) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, errors.New("valid numeric id is required"))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func consumeNATS(ctx context.Context, cfg Config, db *pgxpool.Pool, broker *Broker) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
nc, err := nats.Connect(
|
||||
cfg.NATSURL,
|
||||
nats.Name("live-inventory-cdc-demo"),
|
||||
nats.RetryOnFailedConnect(true),
|
||||
nats.MaxReconnects(-1),
|
||||
nats.ReconnectWait(2*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("connect nats: %v", err)
|
||||
sleepOrDone(ctx, 2*time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
sub, err := nc.Subscribe(cfg.NATSSubject, func(msg *nats.Msg) {
|
||||
event, err := normalizeCDC(ctx, db, msg.Data)
|
||||
if err != nil {
|
||||
log.Printf("normalize cdc event: %v", err)
|
||||
return
|
||||
}
|
||||
broker.Publish(event)
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("subscribe nats: %v", err)
|
||||
nc.Close()
|
||||
sleepOrDone(ctx, 2*time.Second)
|
||||
continue
|
||||
}
|
||||
log.Printf("subscribed to nats subject %s", cfg.NATSSubject)
|
||||
|
||||
<-ctx.Done()
|
||||
_ = sub.Unsubscribe()
|
||||
nc.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCDC(ctx context.Context, db *pgxpool.Pool, payload []byte) (CDCEvent, error) {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(payload, &raw); err != nil {
|
||||
return CDCEvent{}, err
|
||||
}
|
||||
|
||||
op, _ := raw["op"].(string)
|
||||
table := ""
|
||||
if source, ok := raw["source"].(map[string]any); ok {
|
||||
table, _ = source["table"].(string)
|
||||
}
|
||||
if table == "" {
|
||||
table = "inventory"
|
||||
}
|
||||
|
||||
items, err := listInventory(ctx, db)
|
||||
if err != nil {
|
||||
return CDCEvent{}, err
|
||||
}
|
||||
|
||||
return CDCEvent{
|
||||
CRUD: crudLabel(op),
|
||||
Op: op,
|
||||
Table: table,
|
||||
Before: raw["before"],
|
||||
After: raw["after"],
|
||||
Raw: append(json.RawMessage(nil), payload...),
|
||||
Inventory: items,
|
||||
Received: time.Now().UTC(),
|
||||
Source: "Debezium -> NATS JetStream -> SSE",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func crudLabel(op string) string {
|
||||
switch op {
|
||||
case "c":
|
||||
return "CREATE"
|
||||
case "r":
|
||||
return "SNAPSHOT"
|
||||
case "u":
|
||||
return "UPDATE"
|
||||
case "d":
|
||||
return "DELETE"
|
||||
default:
|
||||
return strings.ToUpper(op)
|
||||
}
|
||||
}
|
||||
|
||||
func listInventory(ctx context.Context, db *pgxpool.Pool) ([]InventoryItem, error) {
|
||||
rows, err := db.Query(ctx, `
|
||||
select id, sku, name, quantity, location, updated_at
|
||||
from inventory
|
||||
order by id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]InventoryItem, 0)
|
||||
for rows.Next() {
|
||||
var item InventoryItem
|
||||
if err := rows.Scan(&item.ID, &item.SKU, &item.Name, &item.Quantity, &item.Location, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func NewBroker() *Broker {
|
||||
return &Broker{clients: make(map[chan CDCEvent]struct{})}
|
||||
}
|
||||
|
||||
func (b *Broker) Subscribe() chan CDCEvent {
|
||||
ch := make(chan CDCEvent, 8)
|
||||
b.mu.Lock()
|
||||
b.clients[ch] = struct{}{}
|
||||
b.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (b *Broker) Unsubscribe(ch chan CDCEvent) {
|
||||
b.mu.Lock()
|
||||
delete(b.clients, ch)
|
||||
close(ch)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Broker) Publish(event CDCEvent) {
|
||||
b.mu.Lock()
|
||||
b.last = &event
|
||||
for ch := range b.clients {
|
||||
select {
|
||||
case ch <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Broker) Last() *CDCEvent {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.last == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *b.last
|
||||
return ©
|
||||
}
|
||||
|
||||
func writeSSE(w http.ResponseWriter, event CDCEvent) {
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "event: cdc\n")
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, err error) {
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
func sleepOrDone(ctx context.Context, d time.Duration) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
|
||||
func logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user