Skip to content

Commit 2fb9447

Browse files
committed
fix(web): remove demo delay and harden bridge recovery
1 parent 298f3d1 commit 2fb9447

7 files changed

Lines changed: 128 additions & 45 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ web/test-results/
2727

2828
# App data
2929
*.log
30+
logs/current-hardware-bridge-url.txt
3031
*.db
3132
*.sqlite
3233
*.sqlite3

scripts/start_public_hardware_bridge.ps1

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,46 @@ function Test-PortBusy {
7777
return $null -ne (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1)
7878
}
7979

80+
function Resolve-CloudflaredExecutable {
81+
param([string]$Candidate)
82+
$command = Get-Command $Candidate -ErrorAction SilentlyContinue
83+
if ($null -ne $command) {
84+
return $command.Source
85+
}
86+
$knownPaths = @(
87+
(Join-Path ${env:ProgramFiles(x86)} "cloudflared\cloudflared.exe"),
88+
(Join-Path $env:ProgramFiles "cloudflared\cloudflared.exe"),
89+
(Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links\cloudflared.exe")
90+
)
91+
$installed = $knownPaths | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1
92+
if ($installed) {
93+
return $installed
94+
}
95+
throw "cloudflared was not found. Install Cloudflare Tunnel, then rerun this script."
96+
}
97+
98+
function Save-CurrentTunnelUrl {
99+
param([int]$WaitSeconds = 15)
100+
$deadline = (Get-Date).AddSeconds($WaitSeconds)
101+
$pattern = "https://[-a-z0-9]+\.trycloudflare\.com"
102+
while ((Get-Date) -lt $deadline) {
103+
if (Test-Path -LiteralPath $err) {
104+
$match = Select-String -Path $err -Pattern $pattern | Select-Object -Last 1
105+
if ($match) {
106+
$urlMatch = [Regex]::Match($match.Line, $pattern)
107+
if ($urlMatch.Success) {
108+
$currentUrl = $urlMatch.Value
109+
Set-Content -LiteralPath (Join-Path $LogDir "current-hardware-bridge-url.txt") -Value $currentUrl -Encoding UTF8
110+
Write-Host "Hardware bridge URL: $currentUrl"
111+
return
112+
}
113+
}
114+
}
115+
Start-Sleep -Milliseconds 500
116+
}
117+
Write-Warning "Tunnel started but its public URL was not found within $WaitSeconds seconds. Check $err."
118+
}
119+
80120
function Get-PortOwnerPid {
81121
param([int]$Port)
82122
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
@@ -93,7 +133,14 @@ function Test-RepoAgentProcess {
93133
return $false
94134
}
95135
$rootPattern = [Regex]::Escape([string]$Root)
96-
return $proc.CommandLine -match "scripts[/\\]run_agent\.py" -and $proc.CommandLine -match $rootPattern
136+
if ($proc.CommandLine -notmatch "scripts[/\\]run_agent\.py") {
137+
return $false
138+
}
139+
if ($proc.CommandLine -match $rootPattern -or $proc.ExecutablePath -match $rootPattern) {
140+
return $true
141+
}
142+
$parent = Get-CimInstance Win32_Process -Filter "ProcessId = $($proc.ParentProcessId)" -ErrorAction SilentlyContinue
143+
return $null -ne $parent -and $parent.CommandLine -match $rootPattern
97144
}
98145

99146
function Start-Agent {
@@ -156,9 +203,7 @@ if (-not $NoAgentStart) {
156203
$ownerPid = Get-PortOwnerPid $AgentPort
157204
if ($null -ne $ownerPid) {
158205
if (Test-RepoAgentProcess $ownerPid) {
159-
Stop-Process -Id $ownerPid -Force
160-
Start-Sleep -Seconds 2
161-
Start-Agent
206+
Write-Host "Repo agent already running on port $AgentPort (PID $ownerPid)."
162207
} else {
163208
throw "Port $AgentPort is busy by PID $ownerPid and is not this repo agent."
164209
}
@@ -171,24 +216,29 @@ if (-not (Test-PortBusy $AgentPort)) {
171216
throw "Local agent is not listening on port $AgentPort."
172217
}
173218

174-
$cloudflared = Get-Command $CloudflaredPath -ErrorAction SilentlyContinue
175-
if ($null -eq $cloudflared) {
176-
throw "cloudflared was not found. Install Cloudflare Tunnel, then rerun this script."
177-
}
219+
$cloudflared = Resolve-CloudflaredExecutable $CloudflaredPath
178220

179221
Start-SupabaseStateBridge
180222

181223
$out = Join-Path $LogDir "public-hardware-bridge.out.log"
182224
$err = Join-Path $LogDir "public-hardware-bridge.err.log"
183225
$url = "http://127.0.0.1:$AgentPort"
226+
$existingTunnel = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
227+
Where-Object { $_.Name -eq "cloudflared.exe" -and $_.CommandLine -match [Regex]::Escape($url) } |
228+
Select-Object -First 1
229+
if ($null -ne $existingTunnel) {
230+
Write-Host "Cloudflare Tunnel already running (PID $($existingTunnel.ProcessId))."
231+
Save-CurrentTunnelUrl -WaitSeconds 2
232+
exit 0
233+
}
184234
Start-Process `
185-
-FilePath $cloudflared.Source `
235+
-FilePath $cloudflared `
186236
-ArgumentList @("tunnel", "--url", $url) `
187237
-WorkingDirectory $Root `
188238
-RedirectStandardOutput $out `
189239
-RedirectStandardError $err `
190240
-WindowStyle Hidden
191241

192242
Write-Host "Started Cloudflare Tunnel for $url"
193-
Write-Host "Watch $err for the generated https://*.trycloudflare.com URL."
243+
Save-CurrentTunnelUrl
194244
Write-Host "Set that URL as TRASH_SORTER_HARDWARE_BRIDGE_URL in Vercel production."

scripts/supabase_hardware_bridge.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,22 @@ def main() -> int:
4141
if not database_url:
4242
raise SystemExit(f"Set {SUPABASE_DB_ENV} to the Supabase pooled/direct Postgres URL.")
4343

44+
retry_delay = max(1.0, args.interval)
4445
while True:
45-
with psycopg.connect(database_url, autocommit=False, prepare_threshold=None) as conn:
46-
sync_once(conn, args.operations_db, args.history_db, args.history_limit)
47-
conn.commit()
48-
if args.once:
49-
return 0
50-
time.sleep(max(1.0, args.interval))
46+
try:
47+
with psycopg.connect(database_url, autocommit=False, prepare_threshold=None) as conn:
48+
sync_once(conn, args.operations_db, args.history_db, args.history_limit)
49+
conn.commit()
50+
retry_delay = max(1.0, args.interval)
51+
if args.once:
52+
return 0
53+
time.sleep(retry_delay)
54+
except psycopg.Error as exc:
55+
if args.once:
56+
raise
57+
LOGGER.warning("Supabase sync failed; retrying in %.1fs: %s", retry_delay, exc)
58+
time.sleep(retry_delay)
59+
retry_delay = min(30.0, retry_delay * 2)
5160

5261

5362
def sync_once(conn: psycopg.Connection[Any], operations_db: Path, history_db: Path, history_limit: int) -> None:

web/src/components/dashboard-client.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -689,10 +689,15 @@ export function DashboardClient() {
689689
return;
690690
}
691691
const hasDanger = freshAlerts.some((alert) => alert.severity === "danger");
692+
const uniqueMessages = [...new Set(freshAlerts.map((alert) => alert.message || alert.title))];
693+
const visibleMessages = uniqueMessages.slice(0, 5);
694+
if (uniqueMessages.length > visibleMessages.length) {
695+
visibleMessages.push(`Còn ${uniqueMessages.length - visibleMessages.length} cảnh báo khác trên bản đồ.`);
696+
}
692697
setBinFullPopup({
693698
title: hasDanger ? "Có thùng rác đã đầy" : "Có thùng rác gần đầy",
694699
severity: hasDanger ? "danger" : "warning",
695-
messages: freshAlerts.map((alert) => alert.message || alert.title)
700+
messages: visibleMessages
696701
});
697702
setNotice(freshAlerts[0].message || freshAlerts[0].title);
698703
}
@@ -1631,6 +1636,31 @@ export function DashboardClient() {
16311636
normalizedSearch
16321637
]);
16331638

1639+
useEffect(() => {
1640+
if (auth?.role !== "admin" || auth.password_default || !agentToken || !USE_CLOUD_HARDWARE_BRIDGE) {
1641+
return;
1642+
}
1643+
let cancelled = false;
1644+
let checking = false;
1645+
const checkBridge = async () => {
1646+
if (cancelled || checking || document.visibilityState === "hidden") return;
1647+
checking = true;
1648+
try {
1649+
await fetchHardware<RuntimeStatus>("/api/status?include_devices=false", { timeoutMs: 6000 });
1650+
} catch {
1651+
// fetchHardware updates the connection card state.
1652+
} finally {
1653+
checking = false;
1654+
}
1655+
};
1656+
void checkBridge();
1657+
const timer = window.setInterval(() => void checkBridge(), 10_000);
1658+
return () => {
1659+
cancelled = true;
1660+
window.clearInterval(timer);
1661+
};
1662+
}, [agentToken, auth?.role, auth?.password_default]);
1663+
16341664
useEffect(() => {
16351665
const savedToken = window.localStorage.getItem(SESSION_TOKEN_KEY) ?? "";
16361666
const savedChatbot = window.localStorage.getItem(USER_CHATBOT_ENABLED_KEY);

web/src/components/operations-bin-map.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -752,7 +752,7 @@ function BinFillRow({
752752
onClick={onSelectDemoBin}
753753
type="button"
754754
>
755-
{pending ? "Đang chọn..." : selected ? "Đã chọn" : "Chọn"}
755+
{selected ? (pending ? "Đã chọn · Đang lưu" : "Đã chọn") : pending ? "Đang lưu" : "Chọn"}
756756
</button>
757757
) : null}
758758
</div>

web/src/lib/server/cloud-operations.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ type BinRow = QueryResultRow & {
9090
status: string | null;
9191
active: boolean | number;
9292
updated_at: Date | string | null;
93+
station_name?: string | null;
9394
};
9495

9596
type AlertRow = QueryResultRow & {
@@ -421,33 +422,22 @@ export async function cloudSetDemoHardwareTarget(
421422
}
422423

423424
const ownerFilter = identity.role === "admin" ? text(payload.owner_username) : identity.username;
424-
const targetScope = await pool().query<{
425-
assigned_owner_username: string;
426-
bin_id: string;
427-
bin_index: number | string;
428-
}>(
429-
`select station.assigned_owner_username, bin.bin_id, bin.bin_index
425+
const result = await pool().query<DemoHardwareTargetRow>(
426+
`insert into public.demo_hardware_targets
427+
(owner_username, station_id, bin_id, bin_index, selected_by, selected_at, active)
428+
select station.assigned_owner_username, station.station_id, bin.bin_id, bin.bin_index,
429+
$5, now(), true
430430
from public.bin_stations station
431431
join public.bins bin on bin.station_id = station.station_id
432432
where station.station_id = $1
433433
and bin.bin_index = $2
434434
and ($3::text = '' or bin.bin_id = $3)
435435
and ($4::text = '' or station.assigned_owner_username = $4)
436+
and nullif(station.assigned_owner_username, '') is not null
436437
and coalesce(station.active::text, '') not in ('0', 'false', 'f', 'no', '')
437438
and coalesce(bin.active::text, '') not in ('0', 'false', 'f', 'no', '')
438439
order by bin.bin_id
439-
limit 1`,
440-
[stationId, binIndex, requestedBinId, ownerFilter]
441-
);
442-
const scopedTarget = targetScope.rows[0];
443-
if (!scopedTarget?.assigned_owner_username) {
444-
return null;
445-
}
446-
447-
const result = await pool().query<DemoHardwareTargetRow>(
448-
`insert into public.demo_hardware_targets
449-
(owner_username, station_id, bin_id, bin_index, selected_by, selected_at, active)
450-
values ($1, $2, $3, $4, $5, now(), true)
440+
limit 1
451441
on conflict (owner_username) do update set
452442
station_id = excluded.station_id,
453443
bin_id = excluded.bin_id,
@@ -456,10 +446,13 @@ export async function cloudSetDemoHardwareTarget(
456446
selected_at = excluded.selected_at,
457447
active = true
458448
returning owner_username, station_id, bin_id, bin_index, selected_by, selected_at, active`,
459-
[scopedTarget.assigned_owner_username, stationId, scopedTarget.bin_id, binIndex, identity.username]
449+
[stationId, binIndex, requestedBinId, ownerFilter, identity.username]
460450
);
461451

462452
const target = result.rows[0];
453+
if (!target) {
454+
return null;
455+
}
463456
return {
464457
ok: true,
465458
target: {
@@ -636,8 +629,10 @@ async function derivedFullnessAlerts(ownerUsername: string, stationIds: string[]
636629
const result = await pool().query<BinRow>(
637630
`select (row_number() over (order by b.station_id, b.bin_index))::int as id,
638631
b.bin_id, b.station_id, b.command, b.bin_index, b.label,
639-
${fillExpr} as fill_percent, b.status, b.active, b.updated_at
632+
${fillExpr} as fill_percent, b.status, b.active, b.updated_at,
633+
station.name as station_name
640634
from public.bins b
635+
join public.bin_stations station on station.station_id = b.station_id
641636
where ${where.join(" and ")}
642637
order by ${fillExpr} desc, b.updated_at desc`,
643638
values
@@ -655,7 +650,7 @@ async function derivedFullnessAlerts(ownerUsername: string, stationIds: string[]
655650
device_id: "",
656651
severity: danger ? "danger" : "warning",
657652
title: danger ? "\u0054\u0068\u00f9\u006e\u0067\u0020\u0072\u00e1\u0063\u0020\u0111\u00e3\u0020\u0111\u1ea7\u0079" : "\u0054\u0068\u00f9\u006e\u0067\u0020\u0072\u00e1\u0063\u0020\u0067\u1ea7\u006e\u0020\u0111\u1ea7\u0079",
658-
message: `\u0054\u0068\u00f9\u006e\u0067 ${label} ${danger ? "\u0111\u00e3\u0020\u0111\u1ea7\u0079" : "\u0067\u1ea7\u006e\u0020\u0111\u1ea7\u0079"} ${Math.round(fill)}%.`,
653+
message: `${row.station_name || row.station_id}: \u0054\u0068\u00f9\u006e\u0067 ${label} ${danger ? "\u0111\u00e3\u0020\u0111\u1ea7\u0079" : "\u0067\u1ea7\u006e\u0020\u0111\u1ea7\u0079"} ${Math.round(fill)}%.`,
659654
status: "open",
660655
source: "derived_fullness",
661656
created_at: iso(row.updated_at) || now,

web/tests/unit/demo-bin-target.test.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@ describe("demo hardware target hot path", () => {
1919
vi.stubEnv("TRASH_SORTER_DEMO_HARDWARE_TARGET", "1");
2020
vi.stubEnv("TRASH_SORTER_AUTH_DATABASE_URL", "postgresql://test:test@localhost/test");
2121
query.mockReset();
22-
query
23-
.mockResolvedValueOnce({ rows: [{ assigned_owner_username: "alice", bin_id: "station-a-I", bin_index: 3 }] })
24-
.mockResolvedValueOnce({ rows: [{
22+
query.mockResolvedValueOnce({ rows: [{
2523
owner_username: "alice",
2624
station_id: "station-a",
2725
bin_id: "station-a-I",
@@ -38,7 +36,7 @@ describe("demo hardware target hot path", () => {
3836
delete globalThis.trashSorterCloudOperationsPool;
3937
});
4038

41-
it("uses two scoped queries and ignores a forged User owner", async () => {
39+
it("uses one scoped upsert and ignores a forged User owner", async () => {
4240
const result = await cloudSetDemoHardwareTarget(USER, {
4341
station_id: "station-a",
4442
bin_id: "station-a-I",
@@ -47,8 +45,8 @@ describe("demo hardware target hot path", () => {
4745
});
4846

4947
expect(result).toMatchObject({ ok: true, target: { owner_username: "alice", bin_index: 3 } });
50-
expect(query).toHaveBeenCalledTimes(2);
48+
expect(query).toHaveBeenCalledTimes(1);
5149
expect(query.mock.calls.map(([sql]) => String(sql)).join("\n")).not.toMatch(/create table|create index/i);
52-
expect(query.mock.calls[0][1]).toEqual(["station-a", 3, "station-a-I", "alice"]);
50+
expect(query.mock.calls[0][1]).toEqual(["station-a", 3, "station-a-I", "alice", "alice"]);
5351
});
5452
});

0 commit comments

Comments
 (0)