@pi-unipi/info-screen 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -0
- package/index.ts +58 -103
- package/package.json +1 -1
- package/registry.ts +140 -52
- package/tui/info-overlay.ts +274 -349
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# @pi-unipi/info-screen
|
|
2
|
+
|
|
3
|
+
Dashboard and module registry for [Unipi](https://github.com/Neuron-Mr-White/unipi). Shows a configurable info overlay on boot with tabbed groups from all registered modules.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@pi-unipi/info-screen
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or as part of the full suite:
|
|
12
|
+
```bash
|
|
13
|
+
pi install npm:unipi
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Commands
|
|
17
|
+
|
|
18
|
+
| Command | Description |
|
|
19
|
+
|---------|-------------|
|
|
20
|
+
| `/unipi:info` | Show info screen dashboard |
|
|
21
|
+
| `/unipi:info-settings` | Configure info display (groups, stats, visibility) |
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- **Module discovery** — listens for `MODULE_READY` events, tracks all registered modules
|
|
26
|
+
- **Tabbed groups** — each module registers info groups with custom data providers
|
|
27
|
+
- **Configurable** — per-group and per-stat visibility via settings
|
|
28
|
+
- **Boot overlay** — shows dashboard on session start (configurable)
|
|
29
|
+
- **Styled dialog chrome** — uses pi-tui theme API for consistent borders, scrollable content, and navigation hints (matching the overlay style used by @pi-unipi/btw)
|
|
30
|
+
- **Core groups** — modules, tools, load time, session info out of the box
|
|
31
|
+
|
|
32
|
+
## Registering a Group
|
|
33
|
+
|
|
34
|
+
Other modules register info groups via the global registry:
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import { infoRegistry } from "@pi-unipi/info-screen";
|
|
38
|
+
|
|
39
|
+
infoRegistry.registerGroup({
|
|
40
|
+
id: "my-module",
|
|
41
|
+
name: "My Module",
|
|
42
|
+
icon: "📦",
|
|
43
|
+
priority: 60,
|
|
44
|
+
config: {
|
|
45
|
+
showByDefault: true,
|
|
46
|
+
stats: [
|
|
47
|
+
{ id: "status", label: "Status", show: true },
|
|
48
|
+
{ id: "count", label: "Count", show: true },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
dataProvider: async () => ({
|
|
52
|
+
status: { value: "running" },
|
|
53
|
+
count: { value: "42", detail: "items processed" },
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## API
|
|
59
|
+
|
|
60
|
+
### `infoRegistry`
|
|
61
|
+
|
|
62
|
+
Singleton registry instance. Also available globally via `globalThis.__unipi_info_registry`.
|
|
63
|
+
|
|
64
|
+
| Method | Description |
|
|
65
|
+
|--------|-------------|
|
|
66
|
+
| `registerGroup(group)` | Register an info group |
|
|
67
|
+
| `unregisterGroup(id)` | Remove a group |
|
|
68
|
+
| `getGroups()` | Get visible groups (sorted by priority) |
|
|
69
|
+
| `getAllGroups()` | Get all groups (including hidden) |
|
|
70
|
+
| `getGroupData(id)` | Get data for a group (cached) |
|
|
71
|
+
| `updateGroupData(id, data)` | Manually update group data |
|
|
72
|
+
| `getVisibleStats(id)` | Get enabled stats for a group |
|
|
73
|
+
| `invalidateCache(id)` | Invalidate cached data |
|
|
74
|
+
|
|
75
|
+
### Load Tracking
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
import { startLoadTracking, recordLoadTime, finishLoadTracking, recordModuleStart } from "@pi-unipi/info-screen";
|
|
79
|
+
|
|
80
|
+
// Track module load times
|
|
81
|
+
startLoadTracking();
|
|
82
|
+
recordModuleStart("@pi-unipi/memory");
|
|
83
|
+
// ... module loads ...
|
|
84
|
+
recordLoadTime("@pi-unipi/memory", "module", 150);
|
|
85
|
+
finishLoadTracking();
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Settings
|
|
89
|
+
|
|
90
|
+
Configure in pi `settings.json`:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"unipi": {
|
|
95
|
+
"infoScreen": {
|
|
96
|
+
"showOnBoot": true,
|
|
97
|
+
"bootTimeoutMs": 8000,
|
|
98
|
+
"groups": {
|
|
99
|
+
"modules": { "show": true },
|
|
100
|
+
"ralph": { "show": true },
|
|
101
|
+
"memory": { "show": false }
|
|
102
|
+
},
|
|
103
|
+
"groupOrder": ["modules", "ralph", "subagents"]
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Dependencies
|
|
110
|
+
|
|
111
|
+
- `@pi-unipi/core` — shared constants and events
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
package/index.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @pi-unipi/info-screen — Extension entry
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Cache-first reactive dashboard.
|
|
5
|
+
* Opens immediately with cached data, updates in background.
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
8
8
|
* /unipi:info - Show info dashboard
|
|
9
9
|
* /unipi:info-settings - Configure info display
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { ExtensionAPI
|
|
12
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
13
13
|
import { UNIPI_EVENTS, MODULES, UNIPI_PREFIX, emitEvent, getPackageVersion } from "@pi-unipi/core";
|
|
14
14
|
import { infoRegistry } from "./registry.js";
|
|
15
15
|
import { registerCoreGroups, trackModule, trackTool, setPiApi, registerSkillDir, startLoadTracking, recordLoadTime, finishLoadTracking, recordModuleStart } from "./core-groups.js";
|
|
16
16
|
|
|
17
|
-
/** Re-export
|
|
17
|
+
/** Re-export for external use */
|
|
18
18
|
export { infoRegistry, registerSkillDir, startLoadTracking, recordLoadTime, finishLoadTracking, recordModuleStart };
|
|
19
19
|
import { getInfoSettings } from "./config.js";
|
|
20
20
|
import { InfoOverlay } from "./tui/info-overlay.js";
|
|
@@ -23,71 +23,43 @@ import { SettingsOverlay } from "./settings/settings-tui.js";
|
|
|
23
23
|
/** Package version */
|
|
24
24
|
const VERSION = getPackageVersion(new URL(".", import.meta.url).pathname);
|
|
25
25
|
|
|
26
|
-
/** Module ready tracking */
|
|
27
|
-
let moduleReady = false;
|
|
28
|
-
let moduleReadyResolve: (() => void) | null = null;
|
|
29
|
-
const moduleReadyPromise = new Promise<void>((resolve) => {
|
|
30
|
-
moduleReadyResolve = resolve;
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
/** Timeout for waiting for modules */
|
|
34
|
-
const MODULE_WAIT_TIMEOUT_MS = 8000;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Wait for modules to announce, then return.
|
|
38
|
-
*/
|
|
39
|
-
async function waitForModules(): Promise<void> {
|
|
40
|
-
const settings = getInfoSettings();
|
|
41
|
-
const timeoutMs = settings.bootTimeoutMs || MODULE_WAIT_TIMEOUT_MS;
|
|
42
|
-
|
|
43
|
-
// Wait a bit for modules to announce
|
|
44
|
-
// We wait for the full timeout to give all modules time to emit MODULE_READY
|
|
45
|
-
await new Promise<void>((resolve) => setTimeout(resolve, timeoutMs));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
26
|
export default function (pi: ExtensionAPI) {
|
|
49
|
-
// Set pi API reference for tools access
|
|
50
27
|
setPiApi(pi);
|
|
51
28
|
|
|
52
|
-
// Register core groups
|
|
29
|
+
// Register core groups immediately (synchronous)
|
|
53
30
|
registerCoreGroups();
|
|
54
31
|
|
|
55
|
-
|
|
56
|
-
|
|
57
32
|
// Start load tracking
|
|
58
33
|
startLoadTracking();
|
|
59
34
|
|
|
60
|
-
// Listen for module announcements
|
|
35
|
+
// Listen for module announcements — track and trigger reactive updates
|
|
61
36
|
pi.events.on(UNIPI_EVENTS.MODULE_READY, (event: any) => {
|
|
62
37
|
if (event.name && event.name !== MODULES.INFO_SCREEN) {
|
|
63
|
-
// Track the module
|
|
64
38
|
trackModule(event.name, event.version || "unknown");
|
|
65
39
|
recordLoadTime(event.name, "module", event.loadTimeMs);
|
|
66
40
|
|
|
67
|
-
// Invalidate overview
|
|
41
|
+
// Invalidate overview so next fetch picks up new module list
|
|
68
42
|
infoRegistry.invalidateCache("overview");
|
|
69
43
|
|
|
70
|
-
//
|
|
44
|
+
// Trigger background refresh of overview — subscribers will re-render
|
|
45
|
+
infoRegistry.getGroupData("overview");
|
|
46
|
+
|
|
71
47
|
if (event.tools && Array.isArray(event.tools)) {
|
|
72
48
|
for (const tool of event.tools) {
|
|
73
49
|
trackTool(tool, event.name);
|
|
74
50
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (!moduleReady) {
|
|
79
|
-
moduleReady = true;
|
|
80
|
-
moduleReadyResolve?.();
|
|
51
|
+
// Refresh tools group too
|
|
52
|
+
infoRegistry.invalidateCache("tools");
|
|
53
|
+
infoRegistry.getGroupData("tools");
|
|
81
54
|
}
|
|
82
55
|
}
|
|
83
56
|
});
|
|
84
57
|
|
|
85
|
-
// Listen for info group registrations via events
|
|
86
58
|
pi.events.on(UNIPI_EVENTS.INFO_GROUP_REGISTERED, (_event: any) => {
|
|
87
59
|
// Group already registered via globalThis in registerGroup()
|
|
88
60
|
});
|
|
89
61
|
|
|
90
|
-
//
|
|
62
|
+
// Track built-in tools
|
|
91
63
|
const trackedBuiltinTools = new Set<string>();
|
|
92
64
|
pi.on("tool_call", async (event, _ctx) => {
|
|
93
65
|
const toolName = event.toolName;
|
|
@@ -95,50 +67,56 @@ export default function (pi: ExtensionAPI) {
|
|
|
95
67
|
trackedBuiltinTools.add(toolName);
|
|
96
68
|
trackTool(toolName, "builtin");
|
|
97
69
|
}
|
|
98
|
-
return undefined;
|
|
70
|
+
return undefined;
|
|
99
71
|
});
|
|
100
72
|
|
|
101
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Show the info overlay immediately.
|
|
75
|
+
* Cache-first: opens with whatever data is cached (even empty).
|
|
76
|
+
* Background: each group fetches independently, overlay re-renders reactively.
|
|
77
|
+
*/
|
|
78
|
+
function showOverlay(ctx: any): void {
|
|
79
|
+
ctx.ui.custom(
|
|
80
|
+
(tui: any, theme: any, _keybindings: any, done: () => void) => {
|
|
81
|
+
const overlay = new InfoOverlay();
|
|
82
|
+
overlay.setTheme(theme);
|
|
83
|
+
overlay.onClose = () => {
|
|
84
|
+
overlay.destroy();
|
|
85
|
+
done();
|
|
86
|
+
};
|
|
87
|
+
overlay.requestRender = () => tui.requestRender();
|
|
88
|
+
return {
|
|
89
|
+
render: (w: number) => overlay.render(w),
|
|
90
|
+
invalidate: () => overlay.invalidate(),
|
|
91
|
+
handleInput: (data: string) => {
|
|
92
|
+
overlay.handleInput(data);
|
|
93
|
+
tui.requestRender();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
overlay: true,
|
|
99
|
+
overlayOptions: {
|
|
100
|
+
width: "80%",
|
|
101
|
+
minWidth: 60,
|
|
102
|
+
anchor: "center",
|
|
103
|
+
margin: 2,
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Session lifecycle — show immediately on boot, no blocking wait
|
|
102
110
|
pi.on("session_start", async (event, ctx) => {
|
|
103
111
|
const settings = getInfoSettings();
|
|
104
112
|
|
|
105
|
-
// Show dashboard only on initial startup, not on /new
|
|
106
113
|
if (settings.showOnBoot && event.reason === "startup") {
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
// Show the overlay using three-method object pattern
|
|
111
|
-
ctx.ui.custom(
|
|
112
|
-
(tui, _theme, _keybindings, done) => {
|
|
113
|
-
const overlay = new InfoOverlay();
|
|
114
|
-
overlay.onClose = () => done(undefined);
|
|
115
|
-
overlay.requestRender = () => tui.requestRender();
|
|
116
|
-
// Return three-method object as per pi-tui docs
|
|
117
|
-
return {
|
|
118
|
-
render: (w: number) => overlay.render(w),
|
|
119
|
-
invalidate: () => overlay.invalidate(),
|
|
120
|
-
handleInput: (data: string) => {
|
|
121
|
-
overlay.handleInput?.(data);
|
|
122
|
-
tui.requestRender();
|
|
123
|
-
},
|
|
124
|
-
};
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
overlay: true,
|
|
128
|
-
overlayOptions: {
|
|
129
|
-
width: "80%",
|
|
130
|
-
minWidth: 60,
|
|
131
|
-
anchor: "center",
|
|
132
|
-
margin: 2,
|
|
133
|
-
},
|
|
134
|
-
}
|
|
135
|
-
);
|
|
114
|
+
// Open immediately — cache-first, no waiting
|
|
115
|
+
showOverlay(ctx);
|
|
136
116
|
}
|
|
137
117
|
|
|
138
|
-
// Finish load tracking
|
|
139
118
|
finishLoadTracking();
|
|
140
119
|
|
|
141
|
-
// Announce module
|
|
142
120
|
emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
|
|
143
121
|
name: MODULES.INFO_SCREEN,
|
|
144
122
|
version: VERSION,
|
|
@@ -147,43 +125,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
147
125
|
});
|
|
148
126
|
});
|
|
149
127
|
|
|
150
|
-
//
|
|
128
|
+
// /unipi:info — open immediately
|
|
151
129
|
pi.registerCommand(`${UNIPI_PREFIX}info`, {
|
|
152
130
|
description: "Show info screen dashboard",
|
|
153
131
|
handler: async (_args, ctx) => {
|
|
154
|
-
ctx
|
|
155
|
-
(tui, _theme, _keybindings, done) => {
|
|
156
|
-
const overlay = new InfoOverlay();
|
|
157
|
-
overlay.onClose = () => done(undefined);
|
|
158
|
-
overlay.requestRender = () => tui.requestRender();
|
|
159
|
-
return {
|
|
160
|
-
render: (w: number) => overlay.render(w),
|
|
161
|
-
invalidate: () => overlay.invalidate(),
|
|
162
|
-
handleInput: (data: string) => {
|
|
163
|
-
overlay.handleInput?.(data);
|
|
164
|
-
tui.requestRender();
|
|
165
|
-
},
|
|
166
|
-
};
|
|
167
|
-
},
|
|
168
|
-
{
|
|
169
|
-
overlay: true,
|
|
170
|
-
overlayOptions: {
|
|
171
|
-
width: "80%",
|
|
172
|
-
minWidth: 60,
|
|
173
|
-
anchor: "center",
|
|
174
|
-
margin: 2,
|
|
175
|
-
},
|
|
176
|
-
}
|
|
177
|
-
);
|
|
132
|
+
showOverlay(ctx);
|
|
178
133
|
},
|
|
179
134
|
});
|
|
180
135
|
|
|
181
|
-
//
|
|
136
|
+
// /unipi:info-settings
|
|
182
137
|
pi.registerCommand(`${UNIPI_PREFIX}info-settings`, {
|
|
183
138
|
description: "Configure info screen display",
|
|
184
139
|
handler: async (_args, ctx) => {
|
|
185
140
|
ctx.ui.custom(
|
|
186
|
-
(tui, _theme, _keybindings, done) => {
|
|
141
|
+
(tui: any, _theme: any, _keybindings: any, done: any) => {
|
|
187
142
|
const overlay = new SettingsOverlay();
|
|
188
143
|
overlay.onClose = () => done(undefined);
|
|
189
144
|
return {
|
package/package.json
CHANGED
package/registry.ts
CHANGED
|
@@ -1,41 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @pi-unipi/info-screen — Registry
|
|
3
3
|
*
|
|
4
|
-
* Central registry for info groups
|
|
5
|
-
*
|
|
4
|
+
* Central registry for info groups with cache-first reactive model.
|
|
5
|
+
* Groups load independently; overlay subscribes to per-group updates.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { InfoGroup, GroupData } from "./types.js";
|
|
9
|
-
import { getInfoSettings,
|
|
9
|
+
import { getInfoSettings, isStatEnabled } from "./config.js";
|
|
10
|
+
|
|
11
|
+
/** Callback for reactive updates */
|
|
12
|
+
type GroupUpdateCallback = (groupId: string, data: GroupData) => void;
|
|
10
13
|
|
|
11
|
-
/**
|
|
12
|
-
* Registry for info-screen groups.
|
|
13
|
-
*
|
|
14
|
-
* Usage:
|
|
15
|
-
* ```ts
|
|
16
|
-
* import { infoRegistry } from "@pi-unipi/info-screen";
|
|
17
|
-
*
|
|
18
|
-
* // Register a group
|
|
19
|
-
* infoRegistry.registerGroup({
|
|
20
|
-
* id: "memory",
|
|
21
|
-
* name: "Memory",
|
|
22
|
-
* icon: "🧠",
|
|
23
|
-
* priority: 60,
|
|
24
|
-
* config: {
|
|
25
|
-
* showByDefault: true,
|
|
26
|
-
* stats: [
|
|
27
|
-
* { id: "total", label: "Total", show: true },
|
|
28
|
-
* ],
|
|
29
|
-
* },
|
|
30
|
-
* dataProvider: async () => ({
|
|
31
|
-
* total: { value: "42" },
|
|
32
|
-
* }),
|
|
33
|
-
* });
|
|
34
|
-
*
|
|
35
|
-
* // Get all registered groups
|
|
36
|
-
* const groups = infoRegistry.getGroups();
|
|
37
|
-
* ```
|
|
38
|
-
*/
|
|
39
14
|
class InfoRegistry {
|
|
40
15
|
/** Registered groups by id */
|
|
41
16
|
private groups = new Map<string, InfoGroup>();
|
|
@@ -43,18 +18,29 @@ class InfoRegistry {
|
|
|
43
18
|
/** Cached data per group */
|
|
44
19
|
private dataCache = new Map<string, GroupData>();
|
|
45
20
|
|
|
21
|
+
/** Last successful fetch timestamp per group */
|
|
22
|
+
private lastUpdated = new Map<string, number>();
|
|
23
|
+
|
|
46
24
|
/** Cache TTL in ms */
|
|
47
25
|
private cacheTtlMs = 5000;
|
|
48
26
|
|
|
49
|
-
/**
|
|
50
|
-
private
|
|
27
|
+
/** Subscribers per group */
|
|
28
|
+
private subscribers = new Map<string, Set<GroupUpdateCallback>>();
|
|
29
|
+
|
|
30
|
+
/** Global subscribers (any group update) */
|
|
31
|
+
private globalSubscribers = new Set<GroupUpdateCallback>();
|
|
32
|
+
|
|
33
|
+
/** In-flight fetches per group */
|
|
34
|
+
private inflight = new Map<string, Promise<GroupData>>();
|
|
51
35
|
|
|
52
36
|
/**
|
|
53
37
|
* Register an info group.
|
|
54
|
-
*
|
|
38
|
+
* Notifies subscribers so overlays can pick up late-arriving groups.
|
|
55
39
|
*/
|
|
56
40
|
registerGroup(group: InfoGroup): void {
|
|
57
41
|
this.groups.set(group.id, group);
|
|
42
|
+
// Notify that a new group appeared (triggers overlay sync)
|
|
43
|
+
this.notifyGroupRegistered(group.id);
|
|
58
44
|
}
|
|
59
45
|
|
|
60
46
|
/**
|
|
@@ -63,12 +49,12 @@ class InfoRegistry {
|
|
|
63
49
|
unregisterGroup(groupId: string): void {
|
|
64
50
|
this.groups.delete(groupId);
|
|
65
51
|
this.dataCache.delete(groupId);
|
|
66
|
-
this.
|
|
52
|
+
this.lastUpdated.delete(groupId);
|
|
53
|
+
this.subscribers.delete(groupId);
|
|
67
54
|
}
|
|
68
55
|
|
|
69
56
|
/**
|
|
70
57
|
* Get all registered groups, sorted by priority.
|
|
71
|
-
* Respects config — groups with show: false are excluded.
|
|
72
58
|
*/
|
|
73
59
|
getGroups(): InfoGroup[] {
|
|
74
60
|
const settings = getInfoSettings();
|
|
@@ -76,10 +62,8 @@ class InfoRegistry {
|
|
|
76
62
|
|
|
77
63
|
return allGroups
|
|
78
64
|
.filter((group) => {
|
|
79
|
-
// Check group-level visibility
|
|
80
65
|
const groupSettings = settings.groups[group.id];
|
|
81
66
|
if (groupSettings && !groupSettings.show) return false;
|
|
82
|
-
// If no settings, use group's default
|
|
83
67
|
if (!groupSettings && !group.config.showByDefault) return false;
|
|
84
68
|
return true;
|
|
85
69
|
})
|
|
@@ -101,8 +85,32 @@ class InfoRegistry {
|
|
|
101
85
|
return this.groups.get(groupId);
|
|
102
86
|
}
|
|
103
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Synchronous: get cached data for immediate display.
|
|
90
|
+
* Returns null if never fetched.
|
|
91
|
+
*/
|
|
92
|
+
getCachedData(groupId: string): GroupData | null {
|
|
93
|
+
return this.dataCache.get(groupId) ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Synchronous: get last updated timestamp for a group.
|
|
98
|
+
*/
|
|
99
|
+
getLastUpdated(groupId: string): number {
|
|
100
|
+
return this.lastUpdated.get(groupId) ?? 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Synchronous: check if a group is currently fetching.
|
|
105
|
+
*/
|
|
106
|
+
isFetching(groupId: string): boolean {
|
|
107
|
+
return this.inflight.has(groupId);
|
|
108
|
+
}
|
|
109
|
+
|
|
104
110
|
/**
|
|
105
111
|
* Get data for a group, using cache if fresh.
|
|
112
|
+
* Returns immediately from cache if fresh, otherwise fetches in background
|
|
113
|
+
* and notifies subscribers when done.
|
|
106
114
|
*/
|
|
107
115
|
async getGroupData(groupId: string): Promise<GroupData> {
|
|
108
116
|
const group = this.groups.get(groupId);
|
|
@@ -110,44 +118,114 @@ class InfoRegistry {
|
|
|
110
118
|
|
|
111
119
|
// Check cache freshness
|
|
112
120
|
const now = Date.now();
|
|
113
|
-
const lastUpdate = this.
|
|
121
|
+
const lastUpdate = this.lastUpdated.get(groupId) ?? 0;
|
|
114
122
|
if (now - lastUpdate < this.cacheTtlMs) {
|
|
115
123
|
const cached = this.dataCache.get(groupId);
|
|
116
124
|
if (cached) return cached;
|
|
117
125
|
}
|
|
118
126
|
|
|
127
|
+
// Deduplicate in-flight requests
|
|
128
|
+
const existing = this.inflight.get(groupId);
|
|
129
|
+
if (existing) return existing;
|
|
130
|
+
|
|
119
131
|
// Fetch fresh data
|
|
132
|
+
const fetchPromise = this.fetchGroupData(groupId, group);
|
|
133
|
+
this.inflight.set(groupId, fetchPromise);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const data = await fetchPromise;
|
|
137
|
+
return data;
|
|
138
|
+
} finally {
|
|
139
|
+
this.inflight.delete(groupId);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Background fetch: update cache and notify subscribers.
|
|
145
|
+
* Does not throw — falls back to cached data.
|
|
146
|
+
*/
|
|
147
|
+
private async fetchGroupData(groupId: string, group: InfoGroup): Promise<GroupData> {
|
|
120
148
|
try {
|
|
121
149
|
const data = await group.dataProvider();
|
|
122
150
|
this.dataCache.set(groupId, data);
|
|
123
|
-
this.
|
|
151
|
+
this.lastUpdated.set(groupId, Date.now());
|
|
152
|
+
this.notifySubscribers(groupId, data);
|
|
124
153
|
return data;
|
|
125
|
-
} catch
|
|
126
|
-
// Silently fall back to cached data
|
|
154
|
+
} catch {
|
|
127
155
|
return this.dataCache.get(groupId) ?? {};
|
|
128
156
|
}
|
|
129
157
|
}
|
|
130
158
|
|
|
131
159
|
/**
|
|
132
|
-
*
|
|
160
|
+
* Trigger a background refresh for a group.
|
|
161
|
+
* Returns cached data immediately if available.
|
|
162
|
+
*/
|
|
163
|
+
refreshGroup(groupId: string): GroupData | null {
|
|
164
|
+
const cached = this.dataCache.get(groupId) ?? null;
|
|
165
|
+
// Fire and forget
|
|
166
|
+
this.getGroupData(groupId);
|
|
167
|
+
return cached;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Refresh all groups in background.
|
|
172
|
+
*/
|
|
173
|
+
refreshAll(): void {
|
|
174
|
+
for (const [id, group] of this.groups) {
|
|
175
|
+
this.fetchGroupData(id, group);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Subscribe to updates for a specific group.
|
|
181
|
+
* Returns unsubscribe function.
|
|
133
182
|
*/
|
|
134
|
-
|
|
135
|
-
this.
|
|
136
|
-
|
|
183
|
+
subscribe(groupId: string, callback: GroupUpdateCallback): () => void {
|
|
184
|
+
if (!this.subscribers.has(groupId)) {
|
|
185
|
+
this.subscribers.set(groupId, new Set());
|
|
186
|
+
}
|
|
187
|
+
this.subscribers.get(groupId)!.add(callback);
|
|
188
|
+
|
|
189
|
+
return () => {
|
|
190
|
+
this.subscribers.get(groupId)?.delete(callback);
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Subscribe to all group updates.
|
|
196
|
+
* Returns unsubscribe function.
|
|
197
|
+
*/
|
|
198
|
+
subscribeAll(callback: GroupUpdateCallback): () => void {
|
|
199
|
+
this.globalSubscribers.add(callback);
|
|
200
|
+
return () => {
|
|
201
|
+
this.globalSubscribers.delete(callback);
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private notifySubscribers(groupId: string, data: GroupData): void {
|
|
206
|
+
// Per-group subscribers
|
|
207
|
+
const groupSubs = this.subscribers.get(groupId);
|
|
208
|
+
if (groupSubs) {
|
|
209
|
+
for (const cb of groupSubs) {
|
|
210
|
+
try { cb(groupId, data); } catch { /* ignore */ }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Global subscribers
|
|
215
|
+
for (const cb of this.globalSubscribers) {
|
|
216
|
+
try { cb(groupId, data); } catch { /* ignore */ }
|
|
217
|
+
}
|
|
137
218
|
}
|
|
138
219
|
|
|
139
220
|
/**
|
|
140
221
|
* Get filtered stats for a group based on config.
|
|
141
|
-
* Returns stats that are enabled in both group config and settings.
|
|
142
222
|
*/
|
|
143
223
|
getVisibleStats(groupId: string): Array<{ id: string; label: string }> {
|
|
144
224
|
const group = this.groups.get(groupId);
|
|
145
225
|
if (!group) return [];
|
|
146
226
|
|
|
147
227
|
return group.config.stats.filter((stat) => {
|
|
148
|
-
// Check stat-level visibility from settings
|
|
149
228
|
if (!isStatEnabled(groupId, stat.id)) return false;
|
|
150
|
-
// Check stat's own default
|
|
151
229
|
return stat.show;
|
|
152
230
|
});
|
|
153
231
|
}
|
|
@@ -157,7 +235,7 @@ class InfoRegistry {
|
|
|
157
235
|
*/
|
|
158
236
|
invalidateCache(groupId: string): void {
|
|
159
237
|
this.dataCache.delete(groupId);
|
|
160
|
-
this.
|
|
238
|
+
this.lastUpdated.delete(groupId);
|
|
161
239
|
}
|
|
162
240
|
|
|
163
241
|
/**
|
|
@@ -165,7 +243,18 @@ class InfoRegistry {
|
|
|
165
243
|
*/
|
|
166
244
|
invalidateAllCaches(): void {
|
|
167
245
|
this.dataCache.clear();
|
|
168
|
-
this.
|
|
246
|
+
this.lastUpdated.clear();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Notify that a new group was registered.
|
|
251
|
+
* Subscribers can use this to sync group lists.
|
|
252
|
+
*/
|
|
253
|
+
private notifyGroupRegistered(groupId: string): void {
|
|
254
|
+
// Fire a dummy update so overlays re-sync their group list
|
|
255
|
+
for (const cb of this.globalSubscribers) {
|
|
256
|
+
try { cb(groupId, {} as GroupData); } catch { /* ignore */ }
|
|
257
|
+
}
|
|
169
258
|
}
|
|
170
259
|
}
|
|
171
260
|
|
|
@@ -173,7 +262,6 @@ class InfoRegistry {
|
|
|
173
262
|
export const infoRegistry = new InfoRegistry();
|
|
174
263
|
|
|
175
264
|
// Expose globally so other modules can access without direct imports
|
|
176
|
-
// (pi loads extensions independently, so imports may not resolve)
|
|
177
265
|
const globalObj = globalThis as any;
|
|
178
266
|
if (!globalObj.__unipi_info_registry) {
|
|
179
267
|
globalObj.__unipi_info_registry = infoRegistry;
|