Refresh ops center in realtime
This commit is contained in:
@@ -9,8 +9,15 @@ import androidx.annotation.Nullable;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class OpsCenterActivity extends BossScreenActivity {
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private LinearLayout contentRoot;
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
@@ -20,9 +27,28 @@ public class OpsCenterActivity extends BossScreenActivity {
|
||||
contentRoot = new LinearLayout(this);
|
||||
contentRoot.setOrientation(LinearLayout.VERTICAL);
|
||||
replaceContent(contentRoot);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
stopRealtimeUpdates();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
@@ -42,6 +68,63 @@ public class OpsCenterActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!shouldReloadForRealtimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
|
||||
if (eventFingerprint.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(this::reload);
|
||||
}
|
||||
|
||||
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
|
||||
return "app.logs.updated".equals(event.eventName)
|
||||
|| "project.context_risk.updated".equals(event.eventName);
|
||||
}
|
||||
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
pruneRecentRealtimeEvents(now);
|
||||
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
|
||||
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
|
||||
return true;
|
||||
}
|
||||
recentRealtimeEventTimestamps.put(eventFingerprint, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void pruneRecentRealtimeEvents(long now) {
|
||||
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, Long> entry = iterator.next();
|
||||
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void render(JSONObject ops) {
|
||||
replaceContent(contentRoot);
|
||||
contentRoot.removeAllViews();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import android.content.Intent;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class OpsCenterActivityTest {
|
||||
@Test
|
||||
public void appLogsUpdatedEventTriggersReload() throws Exception {
|
||||
TestOpsCenterActivity activity = Robolectric
|
||||
.buildActivity(TestOpsCenterActivity.class, new Intent())
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
activity.reloadEnabled = true;
|
||||
activity.reloadCount = 0;
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent("app.logs.updated", new JSONObject().put("deviceId", "mac-studio"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectContextRiskUpdatedEventTriggersReload() throws Exception {
|
||||
TestOpsCenterActivity activity = Robolectric
|
||||
.buildActivity(TestOpsCenterActivity.class, new Intent())
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
activity.reloadEnabled = true;
|
||||
activity.reloadCount = 0;
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent("project.context_risk.updated", new JSONObject().put("status", "verified"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unrelatedConversationEventDoesNotTriggerReload() throws Exception {
|
||||
TestOpsCenterActivity activity = Robolectric
|
||||
.buildActivity(TestOpsCenterActivity.class, new Intent())
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
activity.reloadEnabled = true;
|
||||
activity.reloadCount = 0;
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(0, activity.reloadCount);
|
||||
}
|
||||
|
||||
public static class TestOpsCenterActivity extends OpsCenterActivity {
|
||||
private boolean reloadEnabled;
|
||||
private int reloadCount;
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (!reloadEnabled) {
|
||||
return;
|
||||
}
|
||||
reloadCount += 1;
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"fileName": "boss-android-v2.5.11-release.apk",
|
||||
"urlPath": "/api/v1/user/ota/package",
|
||||
"sizeBytes": 3354971,
|
||||
"updatedAt": "2026-04-07T07:47:51Z",
|
||||
"sha256": "09649f9fa11f5dec4192088e5fc3f6be025e42cd1519c647e80e10505e99a0b3",
|
||||
"sizeBytes": 3355141,
|
||||
"updatedAt": "2026-04-07T08:01:27Z",
|
||||
"sha256": "2313bb47c3e54a0e22d94ededbf9735869e1918e3c75f5e2a8b9ebaade8246f0",
|
||||
"versionName": "2.5.11",
|
||||
"versionCode": 24,
|
||||
"buildFlavor": "release"
|
||||
|
||||
Binary file not shown.
@@ -9739,7 +9739,7 @@ export async function performOta() {
|
||||
}
|
||||
|
||||
export async function approveRepairTicket(ticketId: string) {
|
||||
return mutateState((state) => {
|
||||
const ticket = await mutateState((state) => {
|
||||
const ticket = state.opsRepairTickets.find((item) => item.ticketId === ticketId);
|
||||
if (!ticket) throw new Error("TICKET_NOT_FOUND");
|
||||
ticket.approvalStatus = "approved";
|
||||
@@ -9748,10 +9748,15 @@ export async function approveRepairTicket(ticketId: string) {
|
||||
ticket.updatedAt = nowIso();
|
||||
return ticket;
|
||||
});
|
||||
publishBossEvent("project.context_risk.updated", {
|
||||
status: "approved",
|
||||
note: ticketId,
|
||||
});
|
||||
return ticket;
|
||||
}
|
||||
|
||||
export async function verifyRepairTicket(ticketId: string) {
|
||||
return mutateState((state) => {
|
||||
const ticket = await mutateState((state) => {
|
||||
const ticket = state.opsRepairTickets.find((item) => item.ticketId === ticketId);
|
||||
if (!ticket) throw new Error("TICKET_NOT_FOUND");
|
||||
ticket.executionStatus = "verified";
|
||||
@@ -9772,4 +9777,9 @@ export async function verifyRepairTicket(ticketId: string) {
|
||||
|
||||
return ticket;
|
||||
});
|
||||
publishBossEvent("project.context_risk.updated", {
|
||||
status: "verified",
|
||||
note: ticketId,
|
||||
});
|
||||
return ticket;
|
||||
}
|
||||
|
||||
121
tests/ops-repair-ticket-events.test.ts
Normal file
121
tests/ops-repair-ticket-events.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
let approveRepairTicket: (typeof import("../src/lib/boss-data"))["approveRepairTicket"];
|
||||
let verifyRepairTicket: (typeof import("../src/lib/boss-data"))["verifyRepairTicket"];
|
||||
let subscribeBossEvents: (typeof import("../src/lib/boss-events"))["subscribeBossEvents"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-ops-events-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const data = await import("../src/lib/boss-data.ts");
|
||||
const events = await import("../src/lib/boss-events.ts");
|
||||
readState = data.readState;
|
||||
writeState = data.writeState;
|
||||
approveRepairTicket = data.approveRepairTicket;
|
||||
verifyRepairTicket = data.verifyRepairTicket;
|
||||
subscribeBossEvents = events.subscribeBossEvents;
|
||||
}
|
||||
|
||||
async function resetOpsState() {
|
||||
const state = await readState();
|
||||
state.opsFaults = [
|
||||
{
|
||||
faultId: "fault-win-camera",
|
||||
faultKey: "WIN_CAMERA_UPLOAD_DELAY",
|
||||
severity: "high",
|
||||
status: "open",
|
||||
nodeId: "mac-studio",
|
||||
serviceName: "boss-local-agent",
|
||||
projectId: "master-agent",
|
||||
threadRef: "thread-ops-1",
|
||||
traceId: "trace-ops-1",
|
||||
runbookId: "runbook-camera-sync",
|
||||
firstSeenAt: "2026-03-25T11:34:00+08:00",
|
||||
lastSeenAt: "2026-03-25T11:58:00+08:00",
|
||||
summary: "摄像头证据上传延迟。",
|
||||
suggestedNextAction: "重试本地 agent 上传。",
|
||||
autoRepairable: true,
|
||||
},
|
||||
];
|
||||
state.opsRepairTickets = [
|
||||
{
|
||||
ticketId: "ticket-win-camera",
|
||||
faultId: "fault-win-camera",
|
||||
title: "重试摄像头证据上传",
|
||||
approvalStatus: "approved",
|
||||
executionStatus: "running",
|
||||
requestedBy: "运维 Agent",
|
||||
approvedBy: "主 Agent",
|
||||
targetNodeId: "mac-studio",
|
||||
actionSummary: "重新触发本地 agent 心跳和摄像头证据上传。",
|
||||
resultSummary: "等待复验中。",
|
||||
createdAt: "2026-03-25T11:42:00+08:00",
|
||||
updatedAt: "2026-03-25T11:57:00+08:00",
|
||||
},
|
||||
];
|
||||
state.opsRepairVerifications = [
|
||||
{
|
||||
verificationId: "verify-win-camera",
|
||||
ticketId: "ticket-win-camera",
|
||||
verifier: "运维审计 Agent",
|
||||
status: "watching",
|
||||
summary: "等待关键帧落库后关闭工单。",
|
||||
verifiedAt: "2026-03-25T11:58:00+08:00",
|
||||
},
|
||||
];
|
||||
await writeState(state);
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
await resetOpsState();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("approve repair ticket publishes project context risk event", async () => {
|
||||
const events: Array<{ event: string; payload: { status?: string; note?: string } }> = [];
|
||||
const unsubscribe = subscribeBossEvents((event, payload) => {
|
||||
events.push({ event, payload });
|
||||
});
|
||||
|
||||
await approveRepairTicket("ticket-win-camera");
|
||||
unsubscribe();
|
||||
|
||||
const latest = events.at(-1);
|
||||
assert.ok(latest);
|
||||
assert.equal(latest.event, "project.context_risk.updated");
|
||||
assert.equal(latest.payload.status, "approved");
|
||||
assert.equal(latest.payload.note, "ticket-win-camera");
|
||||
});
|
||||
|
||||
test("verify repair ticket publishes project context risk event", async () => {
|
||||
const events: Array<{ event: string; payload: { status?: string; note?: string } }> = [];
|
||||
const unsubscribe = subscribeBossEvents((event, payload) => {
|
||||
events.push({ event, payload });
|
||||
});
|
||||
|
||||
await verifyRepairTicket("ticket-win-camera");
|
||||
unsubscribe();
|
||||
|
||||
const latest = events.at(-1);
|
||||
assert.ok(latest);
|
||||
assert.equal(latest.event, "project.context_risk.updated");
|
||||
assert.equal(latest.payload.status, "verified");
|
||||
assert.equal(latest.payload.note, "ticket-win-camera");
|
||||
});
|
||||
Reference in New Issue
Block a user