Skip to content

Commit 49caf91

Browse files
authored
Local Backend managed by VSCode (#12)
* Local VSCode managed backend * Cleanup statusbar UI * Clean up evr panel subscription to hook on startup rather than when the panel is created * Package backend binary into vscode ext * Trigger refresh from event * Windows build does not add .exe
1 parent ba46a40 commit 49caf91

File tree

21 files changed

+1174
-1943
lines changed

21 files changed

+1174
-1943
lines changed

.github/workflows/build-vscode-extensions.yml

Lines changed: 321 additions & 35 deletions
Large diffs are not rendered by default.

cmd/backend/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ var verboseTelemetry = false
3636
func init() {
3737
flag.StringVar(&configFile, "config", configFile, "config file to load")
3838
flag.StringVar(&host.Config.RootPath, "root", host.Config.RootPath, "root path to load profile configuration and dictionaries from")
39+
flag.StringVar(&host.Config.BindType, "bind-type", host.Config.BindType, "bind gRPC service to this address")
3940
flag.StringVar(&host.Config.BindAddress, "bind", host.Config.BindAddress, "bind gRPC service to this address")
4041
flag.BoolVar(&infra.OtelExport, "export-telemetry", infra.OtelExport, "Export logs, metrics, & traces to an OpenTelemetry gRPC Collector")
4142
flag.BoolVarP(&verbose, "verbose", "v", verbose, "print debug log levels")
@@ -174,7 +175,7 @@ func main() {
174175

175176
var lc net.ListenConfig
176177
logger.Info("starting gRPC backend server", "address", host.Config.BindAddress)
177-
lis, err := lc.Listen(ctx, "tcp", host.Config.BindAddress)
178+
lis, err := lc.Listen(ctx, host.Config.BindType, host.Config.BindAddress)
178179
if err != nil {
179180
logger.Error("failed to listen", "err", err)
180181
return

pkg/fprime/settings.go

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,29 @@ import (
1313
var TimeBases map[int]hostutil.TimeSystem
1414

1515
var Config = struct {
16-
DownlinkTimeout util.Duration `toml:"downlink-timeout" comment:"Maximum duration to wait between downlink chunks before giving up on the downlink. Use 0s to wait forever"`
17-
UplinkChunkSize uint32 `toml:"uplink-chunk-size" comment:"Size in bytes of the 'data' portion on file uplinks. Note packets will include additional overhead around the raw data."`
18-
UplinkTruncateSourcePath bool `toml:"uplink-truncate-source-path" comment:"Save uplink bandwidth by sending '/' instead of the real source path of a file."`
19-
UplinkRateLimit float64 `toml:"uplink-rate-limit" comment:"Uplink rate limit in bytes per second, use '0.0' to disable limiter."`
16+
DownlinkTimeout util.Duration `json:"downlinkTimeout" toml:"downlink-timeout" comment:"Maximum duration to wait between downlink chunks before giving up on the downlink. Use 0s to wait forever"`
17+
UplinkChunkSize uint32 `json:"uplinkChunkSize" toml:"uplink-chunk-size" comment:"Size in bytes of the 'data' portion on file uplinks. Note packets will include additional overhead around the raw data."`
18+
UplinkTruncateSourcePath bool `json:"uplinkTruncateSourcePath" toml:"uplink-truncate-source-path" comment:"Save uplink bandwidth by sending '/' instead of the real source path of a file."`
19+
UplinkRateLimit float64 `json:"uplinkRateLimit" toml:"uplink-rate-limit" comment:"Uplink rate limit in bytes per second, use '0.0' to disable limiter."`
2020

21-
PacketDescriptorType pb.IntKind `toml:"packet-descriptor-type"`
22-
OpcodeType pb.IntKind `toml:"opcode-type"`
23-
ChanIdType pb.IntKind `toml:"chan-id-type"`
24-
EventIdType pb.IntKind `toml:"event-id-type"`
25-
PrmIdType pb.IntKind `toml:"prm-id-type"`
26-
TlmPacketizeIdType pb.IntKind `toml:"tlm-packetize-type"`
27-
BuffSizeType pb.IntKind `toml:"buff-size-type"`
28-
EnumStoreType pb.IntKind `toml:"enum-store-type"`
21+
PacketDescriptorType pb.IntKind `json:"packetDescriptorType" toml:"packet-descriptor-type"`
22+
OpcodeType pb.IntKind `json:"opcodeType" toml:"opcode-type"`
23+
ChanIdType pb.IntKind `json:"chanIdType" toml:"chan-id-type"`
24+
EventIdType pb.IntKind `json:"eventIdType" toml:"event-id-type"`
25+
PrmIdType pb.IntKind `json:"prmIdType" toml:"prm-id-type"`
26+
TlmPacketizeIdType pb.IntKind `json:"tlmPacketizeIdType" toml:"tlm-packetize-type"`
27+
BuffSizeType pb.IntKind `json:"buffSizeType" toml:"buff-size-type"`
28+
EnumStoreType pb.IntKind `json:"enumStoreType" toml:"enum-store-type"`
2929

30-
TimeBaseStoreType pb.IntKind `toml:"time-base-store-type"`
31-
TimeContextStoreType pb.IntKind `toml:"time-context-store-type"`
32-
UseTimeBase bool `toml:"use-time-base" comment:"Expect timebase to be encoded into timestamp values"`
33-
UseTimeContext bool `toml:"use-time-context" comment:"Expect time context to be encoded into timestamp values"`
30+
TimeBaseStoreType pb.IntKind `json:"timeBaseStoreType" toml:"time-base-store-type"`
31+
TimeContextStoreType pb.IntKind `json:"timeContextStoreType" toml:"time-context-store-type"`
32+
UseTimeBase bool `json:"useTimeBase" toml:"use-time-base" comment:"Expect timebase to be encoded into timestamp values"`
33+
UseTimeContext bool `json:"useTimeContext" toml:"use-time-context" comment:"Expect time context to be encoded into timestamp values"`
3434

35-
TimeBases map[int]hostutil.TimeSettings `toml:"time-bases" comment:"Time bases SCLK conversions. If no mapping is found UTC is assumed"`
35+
TimeBases map[int]hostutil.TimeSettings `json:"timeBases" toml:"time-bases" comment:"Time bases SCLK conversions. If no mapping is found UTC is assumed"`
3636

37-
TrueValue uint `toml:"true-value" comment:"Binary to encode 'true' boolean values as"`
38-
FalseValue uint `toml:"false-value" comment:"Binary to encode 'false' boolean values as"`
37+
TrueValue uint `json:"trueValue" toml:"true-value" comment:"Binary to encode 'true' boolean values as"`
38+
FalseValue uint `json:"falseValue" toml:"false-value" comment:"Binary to encode 'false' boolean values as"`
3939
}{
4040
DownlinkTimeout: util.Duration(10 * time.Second),
4141
UplinkChunkSize: 256,

pkg/host/settings.go

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import (
44
"fmt"
55
"io"
66

7-
"github.com/pelletier/go-toml/v2"
87
"github.com/nasa/hermes/pkg/infra"
98
"github.com/nasa/hermes/pkg/pb"
9+
"github.com/pelletier/go-toml/v2"
1010
)
1111

1212
var _settings = map[string]any{}
@@ -45,18 +45,20 @@ func RegisterSettings[T any](
4545
}
4646

4747
var Config = struct {
48-
DownlinkRoot string `toml:"downlink-root" comment:"Path to store files downlinked from connected sources"`
49-
RootPath string `toml:"root-path" comment:"Root path to load profile configuration and dictionaries from"`
50-
BindAddress string `toml:"bind-address" comment:"Bind gRPC service to this address"`
51-
StartProfiles bool `toml:"start-profiles" comment:"Start all profiles on backend startup"`
52-
OtelExport *bool `toml:"otel-export" comment:"Export logs, metrics, & traces to an OpenTelemetry gRPC Collector"`
53-
EventAsOtel bool `toml:"event-as-otel" comment:"Export flight-software emitted events as OpenTelemetry logs records"`
54-
ChannelAsOtel bool `toml:"channel-as-otel" comment:"Export flight-software emitted channelized telemetry (numeric only) as OpenTelemetry metrics.\nNote: This logs all datapoints using wall-clock time instead of flight-software time"`
55-
DownlinkAsOtel bool `toml:"downlink-as-otel" comment:"Export file downlink events as OpenTelemetry log records"`
56-
UseErt *bool `toml:"use-ert" comment:"Export OpenTelemetry channels and events using Earth-Return-Time instead of FSW time"`
48+
DownlinkRoot string `json:"downlinkRoot" toml:"downlink-root" comment:"Path to store files downlinked from connected sources"`
49+
RootPath string `json:"rootPath" toml:"root-path" comment:"Root path to load profile configuration and dictionaries from"`
50+
BindType string `json:"bindType" toml:"bind-type" comment:"Bind type gRPC. Options: 'tcp', 'tcp4', 'tcp6', 'unix' or 'unixpacket'"`
51+
BindAddress string `json:"bindAddress" toml:"bind-address" comment:"Bind gRPC service to this address"`
52+
StartProfiles bool `json:"startProfiles" toml:"start-profiles" comment:"Start all profiles on backend startup"`
53+
OtelExport *bool `json:"otelExport" toml:"otel-export" comment:"Export logs, metrics, & traces to an OpenTelemetry gRPC Collector"`
54+
EventAsOtel bool `json:"eventAsOtel" toml:"event-as-otel" comment:"Export flight-software emitted events as OpenTelemetry logs records"`
55+
ChannelAsOtel bool `json:"channelAsOtel" toml:"channel-as-otel" comment:"Export flight-software emitted channelized telemetry (numeric only) as OpenTelemetry metrics.\nNote: This logs all datapoints using wall-clock time instead of flight-software time"`
56+
DownlinkAsOtel bool `json:"downlinkAsOtel" toml:"downlink-as-otel" comment:"Export file downlink events as OpenTelemetry log records"`
57+
UseErt *bool `json:"useErt" toml:"use-ert" comment:"Export OpenTelemetry channels and events using Earth-Return-Time instead of FSW time"`
5758
}{
5859
DownlinkRoot: ".",
5960
RootPath: ".",
61+
BindType: "tcp",
6062
BindAddress: "localhost:6880",
6163
StartProfiles: false,
6264
OtelExport: &infra.OtelExport,

src/extensions/core/app/evrs/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ export function EvrTable() {
320320
return <p style={{
321321
width: "100%",
322322
textAlign: 'center'
323-
}}>Waiting for EVRs...</p>;
323+
}}>Waiting for event records...</p>;
324324
}
325325

326326
return (

src/extensions/core/package.json

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,22 @@
9898
"title": "Operator Signoff",
9999
"icon": "$(check-all)"
100100
},
101+
{
102+
"command": "hermes.host.changeMode",
103+
"title": "Hermes: Change Backend Mode",
104+
"category": "Hermes"
105+
},
106+
{
107+
"command": "hermes.host.changeUrl",
108+
"title": "Hermes: Change Backend URL",
109+
"category": "Hermes"
110+
},
111+
{
112+
"command": "hermes.host.reconnect",
113+
"title": "Hermes: Reconnect to Backend",
114+
"category": "Hermes",
115+
"icon": "$(refresh)"
116+
},
101117
{
102118
"command": "hermes.connections.array.removeItem",
103119
"when": "webviewId == 'hermes.connections' && webviewSection == 'arrayItem' && hasRemove",
@@ -245,19 +261,22 @@
245261
"properties": {
246262
"hermes.host.type": {
247263
"description": "Determines which Hermes backend to use. Remote backends are used when connecting to a testbed or during operations. The external Hermes backend must be running separately to connect to. The local backend is for local development of sequences and procedures and supports loading dictionaries.",
248-
"default": "local",
264+
"default": "offline",
249265
"type": "string",
250266
"enum": [
267+
"offline",
251268
"local",
252269
"remote"
253270
],
254271
"enumItemLabels": [
255272
"Local",
256-
"Remote"
273+
"Remote",
274+
"Offline"
257275
],
258276
"enumDescriptions": [
259-
"Local backends are just for writing sequences and procedures. You can load dictionaries and create notebooks with this backend.",
260-
"Remote backends support creating run profiles for connecting to flight-software and processing telemetry."
277+
"Offline backends are just for writing sequences and procedures. You can load dictionaries and create notebooks with this backend.",
278+
"Local backends support creating profiles for connecting to flight-software and processing telemetry. They start up a backend within VSCode ",
279+
"Remote backends support creating profiles for connecting to flight-software and processing telemetry. They connect to an already running backend."
261280
]
262281
},
263282
"hermes.host.url": {
@@ -289,6 +308,10 @@
289308
"description": "Skip SSL/TLS certificate validation from the server. This is useful when the client is not set up with the proper certificates to allow connecting to internal websites",
290309
"default": false,
291310
"type": "boolean"
311+
},
312+
"hermes.host.binary": {
313+
"description": "Path to a Hermes backend binary to run instead of the pre-installed backend",
314+
"type": "string"
292315
}
293316
}
294317
},
@@ -404,12 +427,18 @@
404427
]
405428
},
406429
"scripts": {
407-
"vscode:prepublish": "yarn clean && yarn compile",
408-
"prepublishOnly": "node ../../../prepublish.js",
409-
"postpublish": "mv package.tmp.json package.json",
430+
"vscode:prepublish": "yarn clean && yarn compile && yarn backend",
410431
"clean": "rm -rf ./out",
411432
"compile": "cd ../../../ && node build src/extensions/core",
412-
"package": "vsce package --yarn"
433+
"package": "vsce package --yarn",
434+
"backend": "cp ../../../out/backend out/backend || cp ../../../out/backend.exe out/backend.exe",
435+
"backend:linux-x64": "cd ../../../ && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 GO_FLAGS='-trimpath -ldflags=\"-s -w\"' make out/backend && cp out/backend src/extensions/core/out/backend",
436+
"backend:linux-arm64": "cd ../../../ && GOOS=linux GOARCH=arm64 CGO_ENABLED=0 GO_FLAGS='-trimpath -ldflags=\"-s -w\"' make out/backend && cp out/backend src/extensions/core/out/backend",
437+
"backend:darwin-x64": "cd ../../../ && GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 GO_FLAGS='-trimpath -ldflags=\"-s -w\"' make out/backend && cp out/backend src/extensions/core/out/backend",
438+
"backend:darwin-arm64": "cd ../../../ && GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 GO_FLAGS='-trimpath -ldflags=\"-s -w\"' make out/backend && cp out/backend src/extensions/core/out/backend",
439+
"backend:win32-x64": "cd ../../../ && GOOS=windows GOARCH=amd64 CGO_ENABLED=0 GO_FLAGS='-trimpath -ldflags=\"-s -w\"' make out/backend && cp out/backend.exe src/extensions/core/out/backend.exe",
440+
"backend:win32-arm64": "cd ../../../ && GOOS=windows GOARCH=arm64 CGO_ENABLED=0 GO_FLAGS='-trimpath -ldflags=\"-s -w\"' make out/backend && cp out/backend.exe src/extensions/core/out/backend.exe",
441+
"package:platform": "yarn clean && yarn compile && yarn backend:${VSCODE_TARGET} && vsce package --yarn --target ${VSCODE_TARGET}"
413442
},
414443
"devDependencies": {
415444
"@commander-js/extra-typings": "^13.1.0",
@@ -419,6 +448,7 @@
419448
"@tanstack/react-virtual": "^3.13.12",
420449
"@types/react": "^19.0.0",
421450
"@types/react-dom": "^19.0.0",
451+
"@types/tmp": "^0.2.6",
422452
"@types/vscode-notebook-renderer": "^1.72.3",
423453
"@vscode-elements/react-elements": "^2.4.0",
424454
"@vscode/codicons": "^0.0.44",
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import * as vscode from 'vscode';
2+
import * as tmp from 'tmp';
3+
4+
import * as Rpc from '@gov.nasa.jpl.hermes/rpc';
5+
import * as Hermes from '@gov.nasa.jpl.hermes/api';
6+
import { Settings } from '@gov.nasa.jpl.hermes/vscode';
7+
8+
export class Local extends Rpc.Client {
9+
grpcClient: Rpc.GrpcClient;
10+
11+
constructor(
12+
client: Rpc.GrpcClient,
13+
readonly task: vscode.TaskExecution,
14+
readonly transportPipe: string,
15+
readonly configFile: tmp.FileResult,
16+
readonly exitWatcher: vscode.Disposable,
17+
log: Hermes.Log
18+
) {
19+
20+
super(log, client);
21+
22+
this.grpcClient = client;
23+
}
24+
25+
static async activate(
26+
context: vscode.ExtensionContext,
27+
log: Hermes.Log,
28+
token?: vscode.CancellationToken
29+
): Promise<Local> {
30+
const workspaceDir = vscode.workspace.workspaceFolders?.[0];
31+
if (!workspaceDir) {
32+
throw new Error("Cannot run local backend outside of workspace");
33+
}
34+
35+
const transportPipe = tmp.tmpNameSync({ template: "hermes-transport-XXXXXX.sock" });;
36+
const configFile = tmp.fileSync({ template: "hermes-config-XXXXXX.json" });
37+
38+
try {
39+
const workingDir = vscode.Uri.joinPath(workspaceDir.uri, ".hermes");
40+
await vscode.workspace.fs.createDirectory(workingDir);
41+
42+
const task = new vscode.Task(
43+
{
44+
label: "Hermes Backend",
45+
type: "shell",
46+
transportPipe: transportPipe,
47+
configFile: configFile.name,
48+
},
49+
vscode.TaskScope.Workspace,
50+
"Hermes",
51+
"hermes",
52+
new vscode.ProcessExecution(
53+
Settings.hostBinary() ?? context.asAbsolutePath("out/backend"),
54+
[
55+
"--root",
56+
workingDir.fsPath,
57+
"--bind-type",
58+
"unix",
59+
"--bind",
60+
transportPipe,
61+
// "--config",
62+
// this.configFile.name
63+
],
64+
{
65+
cwd: workingDir.fsPath,
66+
}
67+
)
68+
);
69+
70+
task.presentationOptions.reveal = vscode.TaskRevealKind.Silent;
71+
task.presentationOptions.panel = vscode.TaskPanelKind.New;
72+
task.presentationOptions.echo = true;
73+
task.presentationOptions.focus = false;
74+
task.presentationOptions.clear = false;
75+
task.presentationOptions.close = false;
76+
77+
const execution = await vscode.tasks.executeTask(task);
78+
const exitTask = vscode.tasks.onDidEndTaskProcess((e) => {
79+
if (e.execution === execution) {
80+
if (e.exitCode !== 0 && e.exitCode !== undefined) {
81+
vscode.commands.executeCommand("hermes.backend.exit", `Backend exited with code: ${e.exitCode}`);
82+
} else {
83+
vscode.commands.executeCommand("hermes.backend.exit");
84+
}
85+
}
86+
});
87+
88+
const cancelTask = token?.onCancellationRequested(() => {
89+
execution.terminate();
90+
});
91+
92+
const client = new Rpc.GrpcClient(log, {
93+
hostAddress: 'unix:///' + transportPipe,
94+
authMethod: Rpc.HostAuthenticationKind.NONE,
95+
});
96+
97+
await client.connect(token);
98+
cancelTask?.dispose();
99+
100+
return new Local(
101+
client,
102+
execution,
103+
transportPipe,
104+
configFile,
105+
exitTask,
106+
log,
107+
);
108+
} catch (err) {
109+
configFile.removeCallback();
110+
throw err;
111+
}
112+
}
113+
114+
dispose(): void {
115+
super.dispose();
116+
117+
this.grpcClient.close();
118+
this.exitWatcher.dispose();
119+
this.task.terminate();
120+
this.configFile.removeCallback();
121+
}
122+
}
Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,15 @@ function generateShortUid(): string {
1010
return randomBytes(4).toString('hex'); // Generate a short UID
1111
}
1212

13-
export class LocalApi implements Hermes.Api {
13+
export class Offline implements Hermes.Api {
1414
private dictionaryCache = new Map<string, Proto.IDictionary>();
1515

1616
constructor(
1717
readonly context: vscode.ExtensionContext,
1818
readonly log: Hermes.Log,
19-
) {
20-
}
19+
) { }
2120

22-
async activate() {
21+
private async _activate() {
2322
if (!this.context.storageUri) {
2423
return;
2524
}
@@ -57,6 +56,16 @@ export class LocalApi implements Hermes.Api {
5756
}
5857
}
5958

59+
static async activate(
60+
context: vscode.ExtensionContext,
61+
log: Hermes.Log,
62+
_token?: vscode.CancellationToken
63+
): Promise<Offline> {
64+
const out = new Offline(context, log);
65+
await out._activate();
66+
return out;
67+
}
68+
6069

6170
getFsw(id: string): Promise<Hermes.Fsw> {
6271
throw new Error(`FSW with id ${id} is not connected`);
@@ -175,11 +184,11 @@ export class LocalApi implements Hermes.Api {
175184
return nullDisposable;
176185
}
177186

178-
onDownlink(): vscode.Disposable {
187+
onFileDownlink(): vscode.Disposable {
179188
return nullDisposable;
180189
}
181190

182-
onUplink(): vscode.Disposable {
191+
onFileUplink(): vscode.Disposable {
183192
return nullDisposable;
184193
}
185194

0 commit comments

Comments
 (0)