@omercnet/paseo-top 0.0.0-stage → 0.0.1-alpha
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/LICENSE +21 -0
- package/OVERVIEW.md +15 -0
- package/README.md +27 -2
- package/client/dial.ts +29 -0
- package/client/footer.tsx +55 -0
- package/client/format.ts +56 -0
- package/client/gauge.tsx +187 -0
- package/client/screen.tsx +165 -0
- package/client/sessions.tsx +91 -0
- package/client/usage.ts +16 -0
- package/index.client.tsx +17 -0
- package/index.server.ts +15 -0
- package/package.json +60 -4
- package/paseo-plugin.json +5 -0
- package/server/names.ts +143 -0
- package/server/processes.ts +265 -0
- package/server/usage.ts +149 -0
- package/shared/usage.ts +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Omer Cohen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/OVERVIEW.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Top shows how busy a host is without leaving your work, and which agent is responsible. It adds a row to the sidebar footer with the host name and its CPU, memory, disk and disk I/O, and a **Top** screen with dials, a five minute history strip for each reading, and a list of load by agent session.
|
|
2
|
+
|
|
3
|
+
Usage always describes one host. With several hosts connected, the footer follows the host you are working in, and the Top screen has Paseo's host picker. Each daemon measures itself; nothing is sent between hosts.
|
|
4
|
+
|
|
5
|
+
**Load by session** adds up the CPU and memory of everything an agent started, not only the agent process. A build, test run or dev server launched by a tool counts toward the agent that launched it. Each row shows the agent and workspace names you gave them, the busiest command in its tree, cores and memory in use, and opens the agent when pressed. A session is flagged with a flame, and counted in the footer, when it holds one busy core for 30 seconds or a quarter of the host's memory. Everything not listed is shown on one "Everything else" line.
|
|
6
|
+
|
|
7
|
+
How processes are matched to agents: Paseo gives every agent session an identifier in its environment, and everything the session starts inherits it. Top reads only that identifier from each process, never the rest of the environment. A process whose environment was cleared by a tool is charged to the nearest ancestor that has one. Only agents that belong to the host you are viewing are listed; processes of agents that belong to other daemons on the same machine are counted under "Everything else", together with processes of no agent and sessions beyond the first ten. Per-session load needs Linux and only sees processes owned by the daemon's user. Containers run outside the agent's process tree and show as unattributed.
|
|
8
|
+
|
|
9
|
+
What the numbers are and are not: load is sampled every ten seconds, so a process that starts and exits between two scans is never seen, and one that is still running is charged for the CPU it used since it started. A session is flagged only when it has held a full core across three scans in a row, so a single burst does not flag it. Memory is each process's resident memory added up, so memory shared between processes of one agent is counted more than once, and the quarter-of-memory rule compares against the machine's memory, not a container's limit. Sessions plus "Everything else" cover the processes that were scanned, not the whole host: kernel time, finished processes and the page cache are outside them.
|
|
10
|
+
|
|
11
|
+
CPU is the share of non-idle time over the last couple of seconds. Memory excludes reclaimable cache on Linux. Disk is the filesystem that holds the daemon user's home directory. Disk I/O is bytes read plus written per second across the physical disks, on Linux. History is kept by the daemon in memory, so it survives a page refresh and is shared by every client, but starts over when the plugin or daemon restarts. A reading the host cannot give shows `--`; if the plugin cannot be reached, the footer says unavailable.
|
|
12
|
+
|
|
13
|
+
The plugin only reads usage counters and process tables. It has no settings and runs no commands.
|
|
14
|
+
|
|
15
|
+
Requires Paseo ^0.11.0.
|
package/README.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Top
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Paseo plugin that shows CPU, memory, disk and disk I/O of a host in the sidebar footer, and which agent sessions are responsible. See [OVERVIEW.md](OVERVIEW.md) for behavior.
|
|
4
|
+
|
|
5
|
+
## Develop
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun install
|
|
9
|
+
cd top
|
|
10
|
+
npm run check && npm run typecheck && npm test
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Verify against an isolated daemon, never the default one:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
.agents/skills/deliver/scripts/dev-daemon.sh up top
|
|
17
|
+
.agents/skills/deliver/scripts/dev-daemon.sh down top
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Layout
|
|
21
|
+
|
|
22
|
+
- `shared/usage.ts`: the `top.usage` RPC contract and history constants.
|
|
23
|
+
- `server/usage.ts`: CPU, memory, disk and I/O readers, and the sampler with its history ring.
|
|
24
|
+
- `server/processes.ts`: `/proc` scan and attribution of processes to agent sessions.
|
|
25
|
+
- `server/names.ts`: joins agent and workspace names onto sessions.
|
|
26
|
+
- `index.server.ts`: wires the sampler to the RPC.
|
|
27
|
+
- `client/`, `index.client.tsx`: footer row, Top screen, dials and session list.
|
|
28
|
+
- `tests/`: unit tests; process tests run against a fake `/proc` tree.
|
package/client/dial.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** A dial sweeps 240 degrees, symmetric around straight up, with a ladder of ticks along it. */
|
|
2
|
+
export const SWEEP = 240;
|
|
3
|
+
export const TICKS = 51;
|
|
4
|
+
export const MAJOR_EVERY = 5;
|
|
5
|
+
|
|
6
|
+
/** Where tick `index` sits on the 0-100 scale. */
|
|
7
|
+
export function tickPercent(index: number): number {
|
|
8
|
+
return (index / (TICKS - 1)) * 100;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Degrees clockwise from straight up for a 0-100 value; an unknown value rests at the start. */
|
|
12
|
+
export function needleDegrees(percent: number | null): number {
|
|
13
|
+
const clamped = Math.min(100, Math.max(0, percent ?? 0));
|
|
14
|
+
return (clamped / 100) * SWEEP - SWEEP / 2;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Bar heights as 0-1 fractions of `scale`, oldest first; unknown samples are 0. */
|
|
18
|
+
export function sparkHeights(values: readonly (number | null)[], scale: number): number[] {
|
|
19
|
+
return values.map((value) => (value === null || scale <= 0 ? 0 : Math.min(1, value / scale)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A screen reader gets no current value for an unknown reading, rather than a false zero. */
|
|
23
|
+
export function accessibilityValue(percent: number | null): {
|
|
24
|
+
min: number;
|
|
25
|
+
max: number;
|
|
26
|
+
now?: number;
|
|
27
|
+
} {
|
|
28
|
+
return percent === null ? { min: 0, max: 100 } : { min: 0, max: 100, now: Math.round(percent) };
|
|
29
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import { Text, View } from "react-native";
|
|
5
|
+
import { formatPercent, formatRate, levelColor } from "./format";
|
|
6
|
+
import { SCREEN_ID, useUsage } from "./usage";
|
|
7
|
+
|
|
8
|
+
export function TopFooter({ theme, host, currentScreen, openScreen }: PluginSidebarItemProps) {
|
|
9
|
+
const usage = useUsage(host.id);
|
|
10
|
+
const heavy = usage.data?.sessions?.filter((session) => session.heavy).length ?? 0;
|
|
11
|
+
const metrics =
|
|
12
|
+
usage.data &&
|
|
13
|
+
([
|
|
14
|
+
["Cpu", formatPercent(usage.data.cpu), usage.data.cpu],
|
|
15
|
+
["MemoryStick", formatPercent(usage.data.memory), usage.data.memory],
|
|
16
|
+
["HardDrive", formatPercent(usage.data.disk), usage.data.disk],
|
|
17
|
+
["Gauge", formatRate(usage.data.io), null],
|
|
18
|
+
] as const);
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<SidebarRow
|
|
22
|
+
icon="Activity"
|
|
23
|
+
label={host.label}
|
|
24
|
+
active={currentScreen?.screenId === SCREEN_ID}
|
|
25
|
+
onPress={() => openScreen({ screenId: SCREEN_ID })}
|
|
26
|
+
trailing={
|
|
27
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
28
|
+
{usage.isError && (
|
|
29
|
+
<Text style={{ color: theme.colors.statusDanger, fontSize: 11 }}>unavailable</Text>
|
|
30
|
+
)}
|
|
31
|
+
{heavy > 0 && (
|
|
32
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 2 }}>
|
|
33
|
+
<Icon name="Flame" size={11} color={theme.colors.statusDanger} />
|
|
34
|
+
<Text style={{ color: theme.colors.statusDanger, fontSize: 11 }}>{heavy}</Text>
|
|
35
|
+
</View>
|
|
36
|
+
)}
|
|
37
|
+
{metrics?.map(([icon, text, percent]) => {
|
|
38
|
+
const color = levelColor(theme, percent);
|
|
39
|
+
return (
|
|
40
|
+
<View key={icon} style={{ flexDirection: "row", alignItems: "center", gap: 2 }}>
|
|
41
|
+
<Icon name={icon} size={11} color={color} />
|
|
42
|
+
<Text
|
|
43
|
+
numberOfLines={1}
|
|
44
|
+
style={{ color, fontSize: 11, fontVariant: ["tabular-nums"] }}
|
|
45
|
+
>
|
|
46
|
+
{text}
|
|
47
|
+
</Text>
|
|
48
|
+
</View>
|
|
49
|
+
);
|
|
50
|
+
})}
|
|
51
|
+
</View>
|
|
52
|
+
}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
package/client/format.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { PluginTheme } from "@getpaseo/plugin";
|
|
2
|
+
|
|
3
|
+
export type Level = "ok" | "warn" | "hot";
|
|
4
|
+
|
|
5
|
+
export function loadLevel(percent: number | null): Level {
|
|
6
|
+
if (percent === null) return "ok";
|
|
7
|
+
if (percent >= 90) return "hot";
|
|
8
|
+
return percent >= 70 ? "warn" : "ok";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatPercent(percent: number | null): string {
|
|
12
|
+
return percent === null ? "--" : `${Math.round(percent)}%`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Scales to the largest unit that keeps the number short, with one decimal below ten. Rounding can
|
|
17
|
+
* carry a value over the next unit (1023.6K is 1.0M, not 1024K), so a unit is promoted on the
|
|
18
|
+
* rounded number.
|
|
19
|
+
*/
|
|
20
|
+
function scaled(value: number, base: number, units: readonly string[]): string {
|
|
21
|
+
let unit = 0;
|
|
22
|
+
let result = value;
|
|
23
|
+
while (Math.round(result) >= base && unit < units.length - 1) {
|
|
24
|
+
result /= base;
|
|
25
|
+
unit++;
|
|
26
|
+
}
|
|
27
|
+
const decimal = unit > 0 && Math.round(result * 10) / 10 < 10;
|
|
28
|
+
return `${decimal ? result.toFixed(1) : Math.round(result)}${units[unit]}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Short throughput such as `0/s`, `850/s`, `12K/s`, `1.5G/s`. */
|
|
32
|
+
export function formatRate(bytesPerSecond: number | null): string {
|
|
33
|
+
return bytesPerSecond === null ? "--" : `${scaled(bytesPerSecond, 1000, ["", "K", "M", "G"])}/s`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** CPU in cores, such as `0.3 cores` or `12 cores`. */
|
|
37
|
+
export function formatCores(cores: number): string {
|
|
38
|
+
return `${Math.round(cores * 10) / 10 < 10 ? cores.toFixed(1) : Math.round(cores)} cores`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Short size such as `0B`, `850B`, `12M`, `2.5G`. */
|
|
42
|
+
export function formatBytes(bytes: number): string {
|
|
43
|
+
return scaled(bytes, 1024, ["B", "K", "M", "G", "T"]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function levelColor(theme: PluginTheme, percent: number | null): string {
|
|
47
|
+
const level = loadLevel(percent);
|
|
48
|
+
if (level === "hot") return theme.colors.statusDanger;
|
|
49
|
+
return level === "warn" ? theme.colors.statusWarning : theme.colors.foregroundMuted;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Like `levelColor`, but a healthy value is green; for dials that should look alive. */
|
|
53
|
+
export function zoneColor(theme: PluginTheme, percent: number): string {
|
|
54
|
+
const color = levelColor(theme, percent);
|
|
55
|
+
return color === theme.colors.foregroundMuted ? theme.colors.statusSuccess : color;
|
|
56
|
+
}
|
package/client/gauge.tsx
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { PluginTheme } from "@getpaseo/plugin";
|
|
2
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { Animated, Easing, Text, View } from "react-native";
|
|
5
|
+
import {
|
|
6
|
+
accessibilityValue,
|
|
7
|
+
MAJOR_EVERY,
|
|
8
|
+
needleDegrees,
|
|
9
|
+
SWEEP,
|
|
10
|
+
sparkHeights,
|
|
11
|
+
TICKS,
|
|
12
|
+
tickPercent,
|
|
13
|
+
} from "./dial";
|
|
14
|
+
import { levelColor, loadLevel, zoneColor } from "./format";
|
|
15
|
+
|
|
16
|
+
const TICK_ROWS = Array.from({ length: TICKS }, (_, index) => index);
|
|
17
|
+
|
|
18
|
+
interface DialProps {
|
|
19
|
+
theme: PluginTheme;
|
|
20
|
+
size: number;
|
|
21
|
+
icon: string;
|
|
22
|
+
label: string;
|
|
23
|
+
/** 0-100, or null when the host cannot report it. */
|
|
24
|
+
value: number | null;
|
|
25
|
+
text: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A speedometer: green, amber and red zones light up to the value, and the needle sweeps there. */
|
|
29
|
+
export function Dial({ theme, size, icon, label, value, text }: DialProps) {
|
|
30
|
+
const radius = size / 2;
|
|
31
|
+
const needleLength = radius * 0.72;
|
|
32
|
+
const degrees = useRef(new Animated.Value(needleDegrees(value))).current;
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
Animated.timing(degrees, {
|
|
35
|
+
toValue: needleDegrees(value),
|
|
36
|
+
duration: 700,
|
|
37
|
+
easing: Easing.out(Easing.cubic),
|
|
38
|
+
useNativeDriver: false,
|
|
39
|
+
}).start();
|
|
40
|
+
}, [degrees, value]);
|
|
41
|
+
const rotate = degrees.interpolate({
|
|
42
|
+
inputRange: [-SWEEP / 2, SWEEP / 2],
|
|
43
|
+
outputRange: [`${-SWEEP / 2}deg`, `${SWEEP / 2}deg`],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<View
|
|
48
|
+
accessible
|
|
49
|
+
accessibilityRole="progressbar"
|
|
50
|
+
accessibilityLabel={`${label} ${text}`}
|
|
51
|
+
accessibilityValue={accessibilityValue(value)}
|
|
52
|
+
style={{ width: size, height: size * 0.9 }}
|
|
53
|
+
>
|
|
54
|
+
{TICK_ROWS.map((index) => {
|
|
55
|
+
const major = index % MAJOR_EVERY === 0;
|
|
56
|
+
const length = major ? 15 : 9;
|
|
57
|
+
const width = major ? 3 : 2;
|
|
58
|
+
const percent = tickPercent(index);
|
|
59
|
+
const lit = value !== null && percent <= value;
|
|
60
|
+
return (
|
|
61
|
+
<View
|
|
62
|
+
key={index}
|
|
63
|
+
style={{
|
|
64
|
+
position: "absolute",
|
|
65
|
+
left: radius - width / 2,
|
|
66
|
+
top: radius - length / 2,
|
|
67
|
+
width,
|
|
68
|
+
height: length,
|
|
69
|
+
borderRadius: width / 2,
|
|
70
|
+
backgroundColor: lit ? zoneColor(theme, percent) : theme.colors.foregroundMuted,
|
|
71
|
+
opacity: lit ? 1 : 0.2,
|
|
72
|
+
transform: [
|
|
73
|
+
{ rotate: `${(percent / 100) * SWEEP - SWEEP / 2}deg` },
|
|
74
|
+
{ translateY: -(radius - 6 - length / 2) },
|
|
75
|
+
],
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
<Animated.View
|
|
81
|
+
style={{
|
|
82
|
+
position: "absolute",
|
|
83
|
+
left: radius - 1.5,
|
|
84
|
+
top: radius - needleLength,
|
|
85
|
+
width: 3,
|
|
86
|
+
height: needleLength,
|
|
87
|
+
borderRadius: 2,
|
|
88
|
+
backgroundColor: value === null ? theme.colors.foregroundMuted : theme.colors.foreground,
|
|
89
|
+
transform: [
|
|
90
|
+
{ translateY: needleLength / 2 },
|
|
91
|
+
{ rotate },
|
|
92
|
+
{ translateY: -needleLength / 2 },
|
|
93
|
+
],
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
<View
|
|
97
|
+
style={{
|
|
98
|
+
position: "absolute",
|
|
99
|
+
left: radius - 8,
|
|
100
|
+
top: radius - 8,
|
|
101
|
+
width: 16,
|
|
102
|
+
height: 16,
|
|
103
|
+
borderRadius: 8,
|
|
104
|
+
borderWidth: 3,
|
|
105
|
+
borderColor: theme.colors.foreground,
|
|
106
|
+
backgroundColor: theme.colors.surface1,
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
109
|
+
<View
|
|
110
|
+
style={{ position: "absolute", left: 0, right: 0, top: radius + 18, alignItems: "center" }}
|
|
111
|
+
>
|
|
112
|
+
<Text
|
|
113
|
+
style={{
|
|
114
|
+
color:
|
|
115
|
+
value === null || loadLevel(value) === "ok"
|
|
116
|
+
? theme.colors.foreground
|
|
117
|
+
: levelColor(theme, value),
|
|
118
|
+
fontSize: size * 0.19,
|
|
119
|
+
fontWeight: "600",
|
|
120
|
+
fontVariant: ["tabular-nums"],
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
{text}
|
|
124
|
+
</Text>
|
|
125
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 4, marginTop: 2 }}>
|
|
126
|
+
<Icon name={icon} size={12} color={theme.colors.foregroundMuted} />
|
|
127
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, letterSpacing: 0.6 }}>
|
|
128
|
+
{label}
|
|
129
|
+
</Text>
|
|
130
|
+
</View>
|
|
131
|
+
</View>
|
|
132
|
+
</View>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface SparklineProps {
|
|
137
|
+
theme: PluginTheme;
|
|
138
|
+
/** Oldest first; `scale` is the value that fills the bar. */
|
|
139
|
+
values: readonly (number | null)[];
|
|
140
|
+
scale: number;
|
|
141
|
+
width: number;
|
|
142
|
+
/** Bars always occupy `slots` positions, so a young history does not stretch. */
|
|
143
|
+
slots: number;
|
|
144
|
+
color: (value: number | null) => string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A strip of bars for the last few minutes, so a spike is still visible after it passes. */
|
|
148
|
+
export function Sparkline({ theme, values, scale, width, slots, color }: SparklineProps) {
|
|
149
|
+
const heights = sparkHeights(values, scale);
|
|
150
|
+
const empty = Math.max(0, slots - values.length);
|
|
151
|
+
return (
|
|
152
|
+
<View
|
|
153
|
+
style={{
|
|
154
|
+
width,
|
|
155
|
+
height: 28,
|
|
156
|
+
flexDirection: "row",
|
|
157
|
+
alignItems: "flex-end",
|
|
158
|
+
gap: 2,
|
|
159
|
+
borderBottomWidth: 1,
|
|
160
|
+
borderBottomColor: theme.colors.border,
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
{Array.from({ length: slots }, (_, slot) => {
|
|
164
|
+
const sample = slot - empty;
|
|
165
|
+
// Slots are positional: samples are only ever appended and have no id.
|
|
166
|
+
const key = slot;
|
|
167
|
+
if (sample < 0) {
|
|
168
|
+
return (
|
|
169
|
+
<View key={key} style={{ flex: 1, height: 2, backgroundColor: theme.colors.border }} />
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return (
|
|
173
|
+
<View
|
|
174
|
+
key={key}
|
|
175
|
+
style={{
|
|
176
|
+
flex: 1,
|
|
177
|
+
height: Math.max(2, (heights[sample] ?? 0) * 28),
|
|
178
|
+
borderRadius: 1,
|
|
179
|
+
backgroundColor: color(values[sample] ?? null),
|
|
180
|
+
opacity: 0.35 + 0.65 * ((slot + 1) / slots),
|
|
181
|
+
}}
|
|
182
|
+
/>
|
|
183
|
+
);
|
|
184
|
+
})}
|
|
185
|
+
</View>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { PluginTheme } from "@getpaseo/plugin";
|
|
2
|
+
import type { PluginScreenProps } from "@getpaseo/plugin/client";
|
|
3
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { ScrollView, Text, View } from "react-native";
|
|
6
|
+
import { HISTORY_EVERY_MS, HISTORY_LENGTH, type Sample } from "../shared/usage";
|
|
7
|
+
import { formatPercent, formatRate, zoneColor } from "./format";
|
|
8
|
+
import { Dial, Sparkline } from "./gauge";
|
|
9
|
+
import { Sessions } from "./sessions";
|
|
10
|
+
import { useUsage } from "./usage";
|
|
11
|
+
|
|
12
|
+
const DIALS = [
|
|
13
|
+
{ label: "CPU", icon: "Cpu", pick: (usage: Sample) => usage.cpu },
|
|
14
|
+
{ label: "MEMORY", icon: "MemoryStick", pick: (usage: Sample) => usage.memory },
|
|
15
|
+
{ label: "DISK", icon: "HardDrive", pick: (usage: Sample) => usage.disk },
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
function Card({
|
|
19
|
+
theme,
|
|
20
|
+
width,
|
|
21
|
+
children,
|
|
22
|
+
}: {
|
|
23
|
+
theme: PluginTheme;
|
|
24
|
+
width: number;
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
}) {
|
|
27
|
+
return (
|
|
28
|
+
<View
|
|
29
|
+
style={{
|
|
30
|
+
width,
|
|
31
|
+
alignItems: "center",
|
|
32
|
+
gap: 12,
|
|
33
|
+
padding: 16,
|
|
34
|
+
borderRadius: 18,
|
|
35
|
+
borderWidth: 1,
|
|
36
|
+
borderColor: theme.colors.border,
|
|
37
|
+
backgroundColor: theme.colors.surface1,
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
{children}
|
|
41
|
+
</View>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The screen header carries Paseo's host picker; the footer follows the host picked here. */
|
|
46
|
+
export function TopScreen({ theme, layout, host, navigation }: PluginScreenProps) {
|
|
47
|
+
const usage = useUsage(host.id);
|
|
48
|
+
const history = usage.data?.history ?? [];
|
|
49
|
+
const dial = layout.compact ? 156 : 200;
|
|
50
|
+
const inner = dial;
|
|
51
|
+
const io = history.map((sample) => sample.io);
|
|
52
|
+
// The live reading is fresher than the newest history point, so it counts toward the peak too.
|
|
53
|
+
const peak = [...io, usage.data?.io ?? null].reduce<number | null>(
|
|
54
|
+
(max, value) => (value === null ? max : Math.max(max ?? 0, value)),
|
|
55
|
+
null,
|
|
56
|
+
);
|
|
57
|
+
// The floor only keeps a near-idle disk from filling the bars; the label shows the real peak.
|
|
58
|
+
const scale = Math.max(1_000_000, peak ?? 0);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<ScrollView
|
|
62
|
+
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
|
|
63
|
+
contentContainerStyle={{ padding: layout.compact ? 16 : 24, gap: 20 }}
|
|
64
|
+
>
|
|
65
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
66
|
+
<Icon name="Activity" size={22} color={theme.colors.foreground} />
|
|
67
|
+
<Text style={{ color: theme.colors.foreground, fontSize: layout.compact ? 20 : 26 }}>
|
|
68
|
+
{host.label}
|
|
69
|
+
</Text>
|
|
70
|
+
<View
|
|
71
|
+
style={{
|
|
72
|
+
width: 8,
|
|
73
|
+
height: 8,
|
|
74
|
+
borderRadius: 4,
|
|
75
|
+
backgroundColor: usage.isError ? theme.colors.statusDanger : theme.colors.statusSuccess,
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
</View>
|
|
79
|
+
{usage.isError && (
|
|
80
|
+
<Text style={{ color: theme.colors.statusDanger }}>
|
|
81
|
+
Usage is unavailable. Check that the top plugin is running on this host.
|
|
82
|
+
</Text>
|
|
83
|
+
)}
|
|
84
|
+
<View
|
|
85
|
+
style={{
|
|
86
|
+
flexDirection: "row",
|
|
87
|
+
flexWrap: "wrap",
|
|
88
|
+
gap: 16,
|
|
89
|
+
justifyContent: layout.compact ? "center" : "flex-start",
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
{DIALS.map(({ label, icon, pick }) => {
|
|
93
|
+
const value = usage.data ? pick(usage.data) : null;
|
|
94
|
+
return (
|
|
95
|
+
<Card key={label} theme={theme} width={dial + 32}>
|
|
96
|
+
<Dial
|
|
97
|
+
theme={theme}
|
|
98
|
+
size={dial}
|
|
99
|
+
icon={icon}
|
|
100
|
+
label={label}
|
|
101
|
+
value={value}
|
|
102
|
+
text={formatPercent(value)}
|
|
103
|
+
/>
|
|
104
|
+
<Sparkline
|
|
105
|
+
theme={theme}
|
|
106
|
+
values={history.map(pick)}
|
|
107
|
+
scale={100}
|
|
108
|
+
width={inner}
|
|
109
|
+
slots={HISTORY_LENGTH}
|
|
110
|
+
color={(sample) =>
|
|
111
|
+
sample === null ? theme.colors.foregroundMuted : zoneColor(theme, sample)
|
|
112
|
+
}
|
|
113
|
+
/>
|
|
114
|
+
</Card>
|
|
115
|
+
);
|
|
116
|
+
})}
|
|
117
|
+
<Card theme={theme} width={dial + 32}>
|
|
118
|
+
<View style={{ height: dial * 0.9, width: dial, justifyContent: "center", gap: 6 }}>
|
|
119
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
120
|
+
<Icon name="Gauge" size={14} color={theme.colors.foregroundMuted} />
|
|
121
|
+
<Text
|
|
122
|
+
style={{ color: theme.colors.foregroundMuted, fontSize: 12, letterSpacing: 0.6 }}
|
|
123
|
+
>
|
|
124
|
+
DISK I/O
|
|
125
|
+
</Text>
|
|
126
|
+
</View>
|
|
127
|
+
<Text
|
|
128
|
+
style={{
|
|
129
|
+
color: theme.colors.foreground,
|
|
130
|
+
fontSize: dial * 0.2,
|
|
131
|
+
fontWeight: "600",
|
|
132
|
+
fontVariant: ["tabular-nums"],
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
{formatRate(usage.data?.io ?? null)}
|
|
136
|
+
</Text>
|
|
137
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>
|
|
138
|
+
{`peak ${formatRate(peak)}`}
|
|
139
|
+
</Text>
|
|
140
|
+
</View>
|
|
141
|
+
<Sparkline
|
|
142
|
+
theme={theme}
|
|
143
|
+
values={io}
|
|
144
|
+
scale={scale}
|
|
145
|
+
width={inner}
|
|
146
|
+
slots={HISTORY_LENGTH}
|
|
147
|
+
color={() => theme.colors.accent}
|
|
148
|
+
/>
|
|
149
|
+
</Card>
|
|
150
|
+
</View>
|
|
151
|
+
{usage.data && (
|
|
152
|
+
<Sessions
|
|
153
|
+
theme={theme}
|
|
154
|
+
sessions={usage.data.sessions}
|
|
155
|
+
other={usage.data.other}
|
|
156
|
+
onOpenAgent={navigation ? (agentId) => navigation.openAgent({ agentId }) : undefined}
|
|
157
|
+
/>
|
|
158
|
+
)}
|
|
159
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>
|
|
160
|
+
Updates every 5 seconds. Bars cover the last {(HISTORY_LENGTH * HISTORY_EVERY_MS) / 60_000}{" "}
|
|
161
|
+
minutes.
|
|
162
|
+
</Text>
|
|
163
|
+
</ScrollView>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { PluginTheme } from "@getpaseo/plugin";
|
|
2
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { Pressable, Text, View } from "react-native";
|
|
4
|
+
import type { Session } from "../shared/usage";
|
|
5
|
+
import { formatBytes, formatCores } from "./format";
|
|
6
|
+
|
|
7
|
+
export function sessionLabel(session: Session): string {
|
|
8
|
+
return session.title || `Agent ${session.agentId.slice(0, 8)}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SessionsProps {
|
|
12
|
+
theme: PluginTheme;
|
|
13
|
+
/** `null` where the host cannot attribute processes to agents. */
|
|
14
|
+
sessions: Session[] | null;
|
|
15
|
+
other: { cores: number; rss: number };
|
|
16
|
+
onOpenAgent?: (agentId: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Load by agent session, busiest first, with what is actually burning it. */
|
|
20
|
+
export function Sessions({ theme, sessions, other, onOpenAgent }: SessionsProps) {
|
|
21
|
+
const muted = { color: theme.colors.foregroundMuted, fontSize: 12 };
|
|
22
|
+
const rows = [
|
|
23
|
+
<Text key="heading" style={{ ...muted, letterSpacing: 0.6 }}>
|
|
24
|
+
LOAD BY SESSION
|
|
25
|
+
</Text>,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
if (sessions === null) {
|
|
29
|
+
rows.push(
|
|
30
|
+
<Text key="unsupported" style={muted}>
|
|
31
|
+
Per-session load needs a Linux host.
|
|
32
|
+
</Text>,
|
|
33
|
+
);
|
|
34
|
+
} else if (sessions.length === 0) {
|
|
35
|
+
rows.push(
|
|
36
|
+
<Text key="empty" style={muted}>
|
|
37
|
+
No agent is running anything right now.
|
|
38
|
+
</Text>,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
for (const session of sessions ?? []) {
|
|
42
|
+
const detail = [session.workspace, session.command].filter(Boolean).join(" · ");
|
|
43
|
+
rows.push(
|
|
44
|
+
<Pressable
|
|
45
|
+
key={session.agentId}
|
|
46
|
+
accessibilityRole={onOpenAgent ? "button" : undefined}
|
|
47
|
+
disabled={!onOpenAgent}
|
|
48
|
+
onPress={() => onOpenAgent?.(session.agentId)}
|
|
49
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 }}
|
|
50
|
+
>
|
|
51
|
+
<View style={{ width: 14 }}>
|
|
52
|
+
{session.heavy && <Icon name="Flame" size={14} color={theme.colors.statusDanger} />}
|
|
53
|
+
</View>
|
|
54
|
+
<View style={{ flex: 1 }}>
|
|
55
|
+
<Text numberOfLines={1} style={{ color: theme.colors.foreground, fontSize: 14 }}>
|
|
56
|
+
{sessionLabel(session)}
|
|
57
|
+
</Text>
|
|
58
|
+
<Text numberOfLines={1} style={muted}>
|
|
59
|
+
{detail}
|
|
60
|
+
</Text>
|
|
61
|
+
</View>
|
|
62
|
+
<View style={{ alignItems: "flex-end" }}>
|
|
63
|
+
<Text
|
|
64
|
+
style={{
|
|
65
|
+
color: session.heavy ? theme.colors.statusDanger : theme.colors.foreground,
|
|
66
|
+
fontSize: 14,
|
|
67
|
+
fontVariant: ["tabular-nums"],
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
{formatCores(session.cores)}
|
|
71
|
+
</Text>
|
|
72
|
+
<Text style={{ ...muted, fontVariant: ["tabular-nums"] }}>
|
|
73
|
+
{formatBytes(session.rss)}
|
|
74
|
+
</Text>
|
|
75
|
+
</View>
|
|
76
|
+
</Pressable>,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (sessions !== null && (other.cores > 0 || other.rss > 0)) {
|
|
80
|
+
rows.push(
|
|
81
|
+
<View key="other" style={{ flexDirection: "row", gap: 10, paddingTop: 6 }}>
|
|
82
|
+
<View style={{ width: 14 }} />
|
|
83
|
+
<Text style={{ ...muted, flex: 1 }}>Everything else</Text>
|
|
84
|
+
<Text style={{ ...muted, fontVariant: ["tabular-nums"] }}>
|
|
85
|
+
{`${formatCores(other.cores)} · ${formatBytes(other.rss)}`}
|
|
86
|
+
</Text>
|
|
87
|
+
</View>,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return <View style={{ gap: 2 }}>{rows}</View>;
|
|
91
|
+
}
|
package/client/usage.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { getUsage } from "../shared/usage";
|
|
4
|
+
|
|
5
|
+
export const SCREEN_ID = "usage";
|
|
6
|
+
export const POLL_MS = 5000;
|
|
7
|
+
|
|
8
|
+
export function useUsage(hostId: string) {
|
|
9
|
+
const loadUsage = useRpc(getUsage);
|
|
10
|
+
// The host id keys the cache so a host switch never shows another host's numbers.
|
|
11
|
+
return useQuery({
|
|
12
|
+
queryKey: ["top", hostId, "usage"],
|
|
13
|
+
queryFn: () => loadUsage({}),
|
|
14
|
+
refetchInterval: POLL_MS,
|
|
15
|
+
});
|
|
16
|
+
}
|