Skip to content

Commit 2da343b

Browse files
committed
fix(web): refresh admin operations realtime
1 parent a0f8cb0 commit 2da343b

7 files changed

Lines changed: 193 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · SemVer.
44

55
## [Unreleased]
66

7+
## [2.1.1] – 2026-06-20
8+
9+
### Added
10+
- Authenticated Admin realtime event cursor and map refresh for bin fullness,
11+
alerts, devices, and reports.
12+
13+
### Fixed
14+
- Admin and User dashboards now receive the same hardware fullness changes
15+
without requiring a manual map refresh.
16+
717
## [2.1.0] – 2026-06-20
818

919
### Added

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Trash Sorter Pro là một hệ sinh thái AI phân loại rác end-to-end:
5454
- [Checklist tích hợp phần cứng](docs/hardware_integration_checklist.md)
5555
- [Operations map local-first](docs/operations-map-local-first.md)
5656
- [Release v2.1.0: cloud map and hardware bridge](docs/releases/v2.1.0.md)
57+
- [Release v2.1.1: Admin and User realtime](docs/releases/v2.1.1.md)
5758

5859
Hướng dẫn chạy web app cho đồng nghiệp: [docs/huong-dan-chay-web-app.md](docs/huong-dan-chay-web-app.md).
5960

@@ -249,9 +250,9 @@ Local operations map:
249250
- Admin APIs manage roles, devices, bin map, alerts, collection schedules, model,
250251
audio, and reports. User APIs are scoped to map, alerts, schedule, mark-collected,
251252
device issue reporting, own history, and own account.
252-
- Supabase bin/alert triggers write scoped `realtime_events`. User dashboard/map
253-
consumes an authenticated event cursor every 1.2 seconds and refreshes after a
254-
hardware fullness event; the six-second map poll remains a connection fallback.
253+
- Supabase bin/alert triggers write scoped `realtime_events`. Admin and User
254+
dashboard maps consume authenticated event cursors every 1.2 seconds and refresh
255+
after a hardware fullness event; the six-second map poll remains a connection fallback.
255256

256257
Public Admin hardware bridge:
257258

docs/releases/v2.1.1.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Trash Sorter Pro v2.1.1 — Admin & User Realtime
2+
3+
**Released:** 2026-06-20
4+
**Production:** <https://trash-sorter-v2.vercel.app>
5+
6+
## Purpose
7+
8+
This patch makes the realtime behavior identical for both dashboard roles.
9+
When the hardware bridge records a fullness change such as `BIN:3:96`, the
10+
relevant Admin and User screens refresh automatically. Neither person needs to
11+
close the popup or press the map refresh button.
12+
13+
## Realtime behavior
14+
15+
```mermaid
16+
sequenceDiagram
17+
participant Sensor as Hardware sensor
18+
participant Bridge as Supabase synchronizer
19+
participant DB as Supabase
20+
participant Admin as Admin dashboard
21+
participant User as Assigned User dashboard
22+
23+
Sensor->>Bridge: BIN:3:96
24+
Bridge->>DB: update selected bin + alert
25+
DB->>DB: write realtime_events row
26+
Admin->>DB: event cursor every 1.2 s
27+
User->>DB: scoped event cursor every 1.2 s
28+
DB-->>Admin: changed event
29+
DB-->>User: changed event for assigned station
30+
Admin->>DB: refresh map + alerts + schedules
31+
User->>DB: refresh scoped map + alerts + schedules
32+
```
33+
34+
- **Admin:** sees every active station, its bin fullness, and alerts.
35+
- **User:** sees only stations assigned to that account.
36+
- **Cursor interval:** 1.2 seconds while a supported operations screen is
37+
visible and the tab is not hidden.
38+
- **Fallback:** a six-second background refresh protects against a temporary
39+
event-cursor failure.
40+
- **No data leakage:** Admin endpoint requires Admin session; User endpoint
41+
applies station-owner filtering server-side.
42+
43+
## Screens covered
44+
45+
| Role | Screens refreshed automatically |
46+
| --- | --- |
47+
| Admin | Bản đồ thùng, Cảnh báo, Thiết bị, Báo cáo |
48+
| User | Tổng quan, Bản đồ, Cảnh báo, Lịch, Thu gom, Báo lỗi thiết bị |
49+
50+
## Verification
51+
52+
1. Open Admin **Bản đồ thùng** and an assigned User **Bản đồ** in separate
53+
browser sessions.
54+
2. Admin assigns the physical sensor to a bin.
55+
3. Send a firmware reading, for example `BIN:3:65`, then `BIN:3:96`.
56+
4. Both maps should update within roughly 1.2 seconds. At 96%, both should show
57+
the full state and the relevant alert.
58+
5. Confirm the User cannot access the Admin event endpoint or unassigned bins.
59+
60+
## Validation completed
61+
62+
- Unit tests cover the Admin event cursor’s unscoped query and existing User
63+
owner-scoped query.
64+
- Production build passes before deployment.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
import { authenticateSession, CloudAuthConfigError, extractBearerToken } from "@/lib/server/cloud-auth";
4+
import { cloudOperationEvents } from "@/lib/server/cloud-operations";
5+
6+
export const runtime = "nodejs";
7+
export const dynamic = "force-dynamic";
8+
9+
export async function GET(request: NextRequest) {
10+
try {
11+
const identity = await authenticateSession(extractBearerToken(request.headers.get("authorization")));
12+
if (!identity) return NextResponse.json({ detail: "Invalid or missing agent token" }, { status: 401 });
13+
if (identity.role !== "admin") return NextResponse.json({ detail: "Admin role is required" }, { status: 403 });
14+
const after = Number(request.nextUrl.searchParams.get("after") ?? 0);
15+
return NextResponse.json(await cloudOperationEvents(identity, Number.isFinite(after) ? after : 0));
16+
} catch (error) {
17+
if (error instanceof CloudAuthConfigError) {
18+
return NextResponse.json({ detail: "Cloud database is not configured" }, { status: 503 });
19+
}
20+
return NextResponse.json({ detail: "Cloud Admin realtime events failed" }, { status: 500 });
21+
}
22+
}

web/src/components/dashboard-client.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,8 @@ export function DashboardClient() {
357357
const userOperationsRefreshInFlightRef = useRef(false);
358358
const userOperationsEventCursorRef = useRef(0);
359359
const userOperationsInteractionUntilRef = useRef(0);
360+
const adminOperationsRefreshInFlightRef = useRef(false);
361+
const adminOperationsEventCursorRef = useRef(0);
360362
const shownBinFullAlertRanksRef = useRef<Map<string, number>>(new Map());
361363

362364
const cameraStream = useMemo(() => {
@@ -909,6 +911,29 @@ export function DashboardClient() {
909911
setOperationsHealth(healthData);
910912
}
911913

914+
async function refreshAdminRealtimeOperations() {
915+
if (adminOperationsRefreshInFlightRef.current) {
916+
return;
917+
}
918+
adminOperationsRefreshInFlightRef.current = true;
919+
try {
920+
const [binMapData, alertsData, schedulesData] = await Promise.all([
921+
cloudFetch<BinMapResponse>("/api/admin/bin-map", { timeoutMs: 45_000 }, agentToken),
922+
cloudFetch<AlertsResponse>("/api/admin/alerts?include_resolved=false", { timeoutMs: 45_000 }, agentToken),
923+
cloudFetch<CollectionSchedulesResponse>("/api/admin/collection-schedules", { timeoutMs: 45_000 }, agentToken)
924+
]);
925+
setAdminBinMap(binMapData);
926+
setAdminAlerts(alertsData);
927+
showNewBinFullnessPopup(alertsData, "admin");
928+
setAdminSchedules(schedulesData);
929+
} catch {
930+
// The full Admin refresh remains available from the map button if a
931+
// transient realtime request fails.
932+
} finally {
933+
adminOperationsRefreshInFlightRef.current = false;
934+
}
935+
}
936+
912937
async function refreshUserOperations(options?: { background?: boolean }) {
913938
if (userOperationsRefreshInFlightRef.current) {
914939
return;
@@ -1777,6 +1802,58 @@ export function DashboardClient() {
17771802
};
17781803
}, [agentToken, auth?.role, auth?.password_default, userView]);
17791804

1805+
useEffect(() => {
1806+
const refreshableAdminTabs: TabId[] = ["bin-map", "alerts", "devices", "reports"];
1807+
if (auth?.role !== "admin" || auth.password_default || !agentToken || !refreshableAdminTabs.includes(active)) {
1808+
return;
1809+
}
1810+
let cancelled = false;
1811+
let eventRequestInFlight = false;
1812+
adminOperationsEventCursorRef.current = 0;
1813+
const pollOperationEvents = async () => {
1814+
if (cancelled || eventRequestInFlight || document.visibilityState === "hidden") {
1815+
return;
1816+
}
1817+
eventRequestInFlight = true;
1818+
try {
1819+
const payload = await cloudFetch<OperationEventsResponse>(
1820+
`/api/admin/operation-events?after=${adminOperationsEventCursorRef.current}`,
1821+
{ timeoutMs: 8000 },
1822+
agentToken
1823+
);
1824+
if (cancelled) return;
1825+
adminOperationsEventCursorRef.current = payload.cursor;
1826+
if (payload.changed) {
1827+
void refreshAdminRealtimeOperations();
1828+
}
1829+
} catch {
1830+
// The six-second fallback below keeps the Admin map current when the
1831+
// event cursor is briefly unavailable.
1832+
} finally {
1833+
eventRequestInFlight = false;
1834+
}
1835+
};
1836+
void pollOperationEvents();
1837+
const eventTimer = window.setInterval(() => void pollOperationEvents(), 1200);
1838+
return () => {
1839+
cancelled = true;
1840+
window.clearInterval(eventTimer);
1841+
};
1842+
}, [active, agentToken, auth?.role, auth?.password_default]);
1843+
1844+
useEffect(() => {
1845+
const refreshableAdminTabs: TabId[] = ["bin-map", "alerts", "devices", "reports"];
1846+
if (auth?.role !== "admin" || auth.password_default || !agentToken || !refreshableAdminTabs.includes(active)) {
1847+
return;
1848+
}
1849+
const timer = window.setInterval(() => {
1850+
if (document.visibilityState !== "hidden") {
1851+
void refreshAdminRealtimeOperations();
1852+
}
1853+
}, 6000);
1854+
return () => window.clearInterval(timer);
1855+
}, [active, agentToken, auth?.role, auth?.password_default]);
1856+
17801857
useEffect(() => {
17811858
const refreshableUserViews: UserView[] = ["dashboard", "map", "alerts", "schedule", "collect", "report-issue"];
17821859
if (auth?.role !== "user" || auth.password_default || !agentToken || !refreshableUserViews.includes(userView)) {

web/src/lib/agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,6 +1066,7 @@ const CLOUD_DASHBOARD_API_PREFIXES = [
10661066
"/api/admin/collection-schedules",
10671067
"/api/admin/demo-bin-target",
10681068
"/api/admin/devices",
1069+
"/api/admin/operation-events",
10691070
"/api/admin/operations/health",
10701071
"/api/admin/roles",
10711072
"/api/user/"

web/tests/unit/operation-events.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ const USER: CloudAuthIdentity = {
1212
password_default: false
1313
};
1414

15+
const ADMIN: CloudAuthIdentity = {
16+
...USER,
17+
role: "admin",
18+
username: "admin"
19+
};
20+
1521
describe("operation realtime event cursor", () => {
1622
const query = vi.fn();
1723

@@ -52,4 +58,13 @@ describe("operation realtime event cursor", () => {
5258
expect(result.events[0]).toMatchObject({ event_name: "bin_status_changed" });
5359
expect(query.mock.calls[0][1]).toEqual([42, "nguyen-son"]);
5460
});
61+
62+
it("lets Admin consume the unscoped operations event cursor", async () => {
63+
query.mockResolvedValueOnce({ rows: [{ cursor: "57" }] });
64+
65+
const result = await cloudOperationEvents(ADMIN, 0);
66+
67+
expect(result).toEqual({ cursor: 57, changed: true, events: [] });
68+
expect(query.mock.calls[0][1]).toEqual([""]);
69+
});
5570
});

0 commit comments

Comments
 (0)