pi-feats 0.1.1
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/README.md +508 -0
- package/extensions/README.md +27 -0
- package/extensions/api-server/PLAN.md +70 -0
- package/extensions/api-server/README.md +103 -0
- package/extensions/api-server/application-log-store.ts +21 -0
- package/extensions/api-server/application-runtime.ts +212 -0
- package/extensions/api-server/application-store.ts +30 -0
- package/extensions/api-server/index.ts +52 -0
- package/extensions/api-server/profile-store.ts +367 -0
- package/extensions/api-server/server.ts +863 -0
- package/extensions/cli-resources.ts +564 -0
- package/extensions/guardrails/index.ts +178 -0
- package/extensions/lib/application-handler-templates.ts +63 -0
- package/extensions/lib/profile-env.ts +61 -0
- package/extensions/lib/profile-sandbox.ts +197 -0
- package/extensions/lib/remote-hosts.ts +392 -0
- package/extensions/pi-console-webui/app/[section]/page.tsx +4 -0
- package/extensions/pi-console-webui/app/api/admin/config/[target]/route.ts +5 -0
- package/extensions/pi-console-webui/app/api/admin/services/[service]/restart/route.ts +5 -0
- package/extensions/pi-console-webui/app/api/auth/login/route.ts +9 -0
- package/extensions/pi-console-webui/app/api/auth/logout/route.ts +3 -0
- package/extensions/pi-console-webui/app/api/message/app/[slug]/route.ts +11 -0
- package/extensions/pi-console-webui/app/api/pi/[...path]/route.ts +31 -0
- package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +2 -0
- package/extensions/pi-console-webui/app/globals.css +41 -0
- package/extensions/pi-console-webui/app/icon.svg +1 -0
- package/extensions/pi-console-webui/app/layout.tsx +5 -0
- package/extensions/pi-console-webui/app/login/page.tsx +11 -0
- package/extensions/pi-console-webui/app/page.tsx +2 -0
- package/extensions/pi-console-webui/app/terminal/page.tsx +4 -0
- package/extensions/pi-console-webui/components/admin-config-form.tsx +16 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +39 -0
- package/extensions/pi-console-webui/components/application-logs.tsx +38 -0
- package/extensions/pi-console-webui/components/application-mappings.tsx +28 -0
- package/extensions/pi-console-webui/components/application-sessions.tsx +11 -0
- package/extensions/pi-console-webui/components/application-settings.tsx +60 -0
- package/extensions/pi-console-webui/components/application-workspace.tsx +14 -0
- package/extensions/pi-console-webui/components/applications.tsx +15 -0
- package/extensions/pi-console-webui/components/chat-workspace.tsx +42 -0
- package/extensions/pi-console-webui/components/console-page.tsx +23 -0
- package/extensions/pi-console-webui/components/console-state.tsx +30 -0
- package/extensions/pi-console-webui/components/console.tsx +115 -0
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +78 -0
- package/extensions/pi-console-webui/components/package-resources.tsx +13 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +41 -0
- package/extensions/pi-console-webui/components/skill-resources.tsx +35 -0
- package/extensions/pi-console-webui/components/skill-source-document-preview.tsx +7 -0
- package/extensions/pi-console-webui/components/skill-source-import.tsx +7 -0
- package/extensions/pi-console-webui/components/skill-sources.tsx +12 -0
- package/extensions/pi-console-webui/components/terminal-client.tsx +39 -0
- package/extensions/pi-console-webui/components/toast.tsx +18 -0
- package/extensions/pi-console-webui/components/ui/button.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/card.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/input.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/switch.tsx +6 -0
- package/extensions/pi-console-webui/components/ui/tabs.tsx +11 -0
- package/extensions/pi-console-webui/components.json +8 -0
- package/extensions/pi-console-webui/index.ts +33 -0
- package/extensions/pi-console-webui/lib/admin-config.ts +22 -0
- package/extensions/pi-console-webui/lib/auth.ts +21 -0
- package/extensions/pi-console-webui/lib/config.ts +15 -0
- package/extensions/pi-console-webui/lib/pi-api.ts +9 -0
- package/extensions/pi-console-webui/lib/utils.ts +3 -0
- package/extensions/pi-console-webui/next-env.d.ts +6 -0
- package/extensions/pi-console-webui/next.config.js +5 -0
- package/extensions/pi-console-webui/postcss.config.js +1 -0
- package/extensions/pi-console-webui/tailwind.config.ts +2 -0
- package/extensions/pi-console-webui/tsconfig.json +41 -0
- package/extensions/profiles.ts +439 -0
- package/extensions/pulse/index.ts +62 -0
- package/extensions/pulse/store.ts +105 -0
- package/extensions/sequential-workflow.ts +270 -0
- package/extensions/skill-sources/index.ts +4 -0
- package/extensions/skill-sources/store.ts +118 -0
- package/package.json +89 -0
- package/scripts/install-nono.sh +34 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
|
|
8
|
+
export const REMOTE_COMMAND_NAMES = ["remote", "add", "remove", "delete", "list"] as const;
|
|
9
|
+
|
|
10
|
+
type RemoteRuntime = "host" | "docker";
|
|
11
|
+
type RemoteHost = {
|
|
12
|
+
host: string;
|
|
13
|
+
port: number;
|
|
14
|
+
user: string;
|
|
15
|
+
piAgentDirectory: string;
|
|
16
|
+
runtime: RemoteRuntime;
|
|
17
|
+
container?: string;
|
|
18
|
+
};
|
|
19
|
+
type LegacyRemoteHost = Omit<RemoteHost, "piAgentDirectory"> & { piDataDirectory: string };
|
|
20
|
+
type RemoteStore = { version: 2; remotes: Record<string, RemoteHost> };
|
|
21
|
+
type RemoteSecrets = { version: 1; passwords: Record<string, string> };
|
|
22
|
+
type DockerContainer = { id: string; name: string; image: string };
|
|
23
|
+
type CommandResult = { code: number; stdout: string; stderr: string };
|
|
24
|
+
|
|
25
|
+
const validName = (name: string) => /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(name);
|
|
26
|
+
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
27
|
+
const remoteDirectory = (root: string) => join(root, "remote-hosts");
|
|
28
|
+
const storePath = (root: string) => join(remoteDirectory(root), "remotes.json");
|
|
29
|
+
const secretsPath = (root: string) => join(remoteDirectory(root), "secrets.json");
|
|
30
|
+
const knownHostsPath = (root: string) => join(remoteDirectory(root), "known_hosts");
|
|
31
|
+
|
|
32
|
+
function fail(message: string): never {
|
|
33
|
+
process.stderr.write(`Remote error: ${message}\n`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function ensureDirectory(root: string) {
|
|
38
|
+
await mkdir(remoteDirectory(root), { recursive: true, mode: 0o700 });
|
|
39
|
+
await chmod(remoteDirectory(root), 0o700);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function legacyPiAgentDirectory(directory: string) {
|
|
43
|
+
if (directory.endsWith("/agent")) return directory;
|
|
44
|
+
return directory === "~" ? "~/agent" : `${directory}/agent`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readStore(root: string): Promise<RemoteStore> {
|
|
48
|
+
const path = storePath(root);
|
|
49
|
+
if (!existsSync(path)) return { version: 2, remotes: {} };
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(await readFile(path, "utf8")) as { version?: unknown; remotes?: unknown };
|
|
52
|
+
if (!parsed.remotes || typeof parsed.remotes !== "object") throw new Error("invalid format");
|
|
53
|
+
if (parsed.version === 2) return { version: 2, remotes: parsed.remotes as Record<string, RemoteHost> };
|
|
54
|
+
if (parsed.version === 1) {
|
|
55
|
+
const remotes = Object.fromEntries(Object.entries(parsed.remotes as Record<string, LegacyRemoteHost>).map(([name, remote]) => [name, {
|
|
56
|
+
...remote,
|
|
57
|
+
piAgentDirectory: legacyPiAgentDirectory(remote.piDataDirectory),
|
|
58
|
+
}]));
|
|
59
|
+
const migrated = { version: 2 as const, remotes };
|
|
60
|
+
await writePrivateJson(path, migrated);
|
|
61
|
+
return migrated;
|
|
62
|
+
}
|
|
63
|
+
throw new Error("unsupported format version");
|
|
64
|
+
} catch (error) {
|
|
65
|
+
fail(`could not read ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function readSecrets(root: string): Promise<RemoteSecrets> {
|
|
70
|
+
const path = secretsPath(root);
|
|
71
|
+
if (!existsSync(path)) return { version: 1, passwords: {} };
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(await readFile(path, "utf8")) as Partial<RemoteSecrets>;
|
|
74
|
+
if (parsed.version !== 1 || !parsed.passwords || typeof parsed.passwords !== "object") throw new Error("invalid format");
|
|
75
|
+
return { version: 1, passwords: parsed.passwords as Record<string, string> };
|
|
76
|
+
} catch (error) {
|
|
77
|
+
fail(`could not read ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function writePrivateJson(path: string, value: unknown) {
|
|
82
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
83
|
+
await chmod(path, 0o600);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function ask(label: string, defaultValue?: string): Promise<string> {
|
|
87
|
+
const prompt = defaultValue === undefined ? `${label}: ` : `${label} [${defaultValue}]: `;
|
|
88
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
89
|
+
try {
|
|
90
|
+
const answer = (await readline.question(prompt)).trim();
|
|
91
|
+
return answer || defaultValue || "";
|
|
92
|
+
} finally {
|
|
93
|
+
readline.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function askSecret(label: string): Promise<string> {
|
|
98
|
+
if (!process.stdin.isTTY) return ask(label);
|
|
99
|
+
process.stdout.write(`${label}: `);
|
|
100
|
+
return new Promise<string>((resolve, reject) => {
|
|
101
|
+
let value = "";
|
|
102
|
+
const stdin = process.stdin;
|
|
103
|
+
const restore = () => {
|
|
104
|
+
stdin.off("data", onData);
|
|
105
|
+
stdin.setRawMode?.(false);
|
|
106
|
+
stdin.pause();
|
|
107
|
+
};
|
|
108
|
+
const done = (result?: string, error?: Error) => {
|
|
109
|
+
restore();
|
|
110
|
+
process.stdout.write("\n");
|
|
111
|
+
if (error) reject(error); else resolve(result ?? "");
|
|
112
|
+
};
|
|
113
|
+
const onData = (chunk: Buffer) => {
|
|
114
|
+
const key = chunk.toString("utf8");
|
|
115
|
+
if (key === "\u0003") return done(undefined, new Error("cancelled"));
|
|
116
|
+
if (key === "\r" || key === "\n") return done(value);
|
|
117
|
+
if (key === "\u007f" || key === "\b") {
|
|
118
|
+
if (value.length) {
|
|
119
|
+
value = value.slice(0, -1);
|
|
120
|
+
process.stdout.write("\b \b");
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (key >= " ") {
|
|
125
|
+
value += key;
|
|
126
|
+
process.stdout.write("*");
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
stdin.setRawMode?.(true);
|
|
130
|
+
stdin.resume();
|
|
131
|
+
stdin.on("data", onData);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function validateHost(value: string) {
|
|
136
|
+
if (!value || /\s/.test(value)) fail("host must not be empty or contain whitespace.");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function validateUser(value: string) {
|
|
140
|
+
if (!value || /\s/.test(value)) fail("user must not be empty or contain whitespace.");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function validatePiAgentDirectory(value: string) {
|
|
144
|
+
if (!value || (!value.startsWith("/") && value !== "~" && !value.startsWith("~/"))) {
|
|
145
|
+
fail("Pi agent directory must be an absolute path or start with '~/'.");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function piAgentDirectoryExpression(directory: string): string {
|
|
150
|
+
if (directory === "~") return '"$HOME"';
|
|
151
|
+
if (directory.startsWith("~/")) return `"$HOME"/${shellQuote(directory.slice(2))}`;
|
|
152
|
+
return shellQuote(directory);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function createAskpass(password: string): Promise<{ env: NodeJS.ProcessEnv; cleanup: () => Promise<void> }> {
|
|
156
|
+
if (!password) return { env: {}, cleanup: async () => {} };
|
|
157
|
+
const directory = await mkdtemp(join(tmpdir(), "pi-remote-askpass-"));
|
|
158
|
+
const path = join(directory, "askpass");
|
|
159
|
+
await writeFile(path, "#!/bin/sh\nprintf '%s' \"$PI_REMOTE_SSH_PASSWORD\"\n", { mode: 0o700 });
|
|
160
|
+
return {
|
|
161
|
+
env: { SSH_ASKPASS: path, SSH_ASKPASS_REQUIRE: "force", DISPLAY: "pi-remote", PI_REMOTE_SSH_PASSWORD: password },
|
|
162
|
+
cleanup: async () => { await rm(directory, { recursive: true, force: true }); },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function runSsh(root: string, remote: Pick<RemoteHost, "host" | "port" | "user">, password: string, command: string, options: { acceptNewHost?: boolean; interactive?: boolean } = {}): Promise<CommandResult> {
|
|
167
|
+
await ensureDirectory(root);
|
|
168
|
+
const askpass = await createAskpass(password);
|
|
169
|
+
const args = [
|
|
170
|
+
...(options.interactive ? ["-tt"] : ["-T"]),
|
|
171
|
+
"-p", String(remote.port),
|
|
172
|
+
"-o", "ConnectTimeout=15",
|
|
173
|
+
"-o", "ServerAliveInterval=15",
|
|
174
|
+
"-o", "ServerAliveCountMax=2",
|
|
175
|
+
"-o", `UserKnownHostsFile=${knownHostsPath(root)}`,
|
|
176
|
+
"-o", `StrictHostKeyChecking=${options.acceptNewHost ? "accept-new" : "yes"}`,
|
|
177
|
+
"-o", "PreferredAuthentications=publickey,password,keyboard-interactive",
|
|
178
|
+
`${remote.user}@${remote.host}`,
|
|
179
|
+
command,
|
|
180
|
+
];
|
|
181
|
+
try {
|
|
182
|
+
return await new Promise<CommandResult>((resolve, reject) => {
|
|
183
|
+
const child = spawn("ssh", args, { stdio: options.interactive ? "inherit" : ["ignore", "pipe", "pipe"], env: { ...process.env, ...askpass.env } });
|
|
184
|
+
if (options.interactive) {
|
|
185
|
+
child.once("error", reject);
|
|
186
|
+
child.once("exit", (code) => resolve({ code: code ?? 1, stdout: "", stderr: "" }));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
let stdout = "", stderr = "";
|
|
190
|
+
child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); });
|
|
191
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
192
|
+
child.once("error", reject);
|
|
193
|
+
child.once("exit", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
194
|
+
});
|
|
195
|
+
} finally {
|
|
196
|
+
await askpass.cleanup();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function remotePiExecutable() {
|
|
201
|
+
return `PI_REMOTE_PI="$(command -v pi 2>/dev/null || for candidate in "$HOME/.local/bin/pi" "$HOME/.hermes/node/bin/pi"; do [ -x "$candidate" ] && { printf '%s\\n' "$candidate"; break; }; done)"; [ -n "$PI_REMOTE_PI" ] || { echo "Pi executable was not found on the remote host." >&2; exit 127; }; PATH="$(dirname "$PI_REMOTE_PI"):$PATH"; export PATH`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function hostPiCommand(remote: RemoteHost, args: string[]) {
|
|
205
|
+
return `${remotePiExecutable()}; env PI_CODING_AGENT_DIR=${piAgentDirectoryExpression(remote.piAgentDirectory)} "$PI_REMOTE_PI"${args.length ? ` ${args.map(shellQuote).join(" ")}` : ""}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function runtimeCommand(remote: RemoteHost, args: string[]) {
|
|
209
|
+
const piCommand = hostPiCommand(remote, args);
|
|
210
|
+
if (remote.runtime === "host") return piCommand;
|
|
211
|
+
if (!remote.container) fail("docker remote is missing its container name.");
|
|
212
|
+
return `docker exec -it ${shellQuote(remote.container)} sh -lc ${shellQuote(piCommand)}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function profileRuntimeCommand(remote: RemoteHost, profile: string, args: string[]) {
|
|
216
|
+
// Let the remote pi-feats package launch the profile. Manually setting the
|
|
217
|
+
// profile environment bypasses its sandbox/session setup and assumes a
|
|
218
|
+
// legacy root/extensions checkout that package installations do not have.
|
|
219
|
+
return runtimeCommand(remote, ["profile", profile, ...args]);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function runtimeBashCommand(remote: RemoteHost, args: string[]) {
|
|
223
|
+
const bashArgs = args.map(shellQuote).join(" ");
|
|
224
|
+
const shell = `cd ${piAgentDirectoryExpression(remote.piAgentDirectory)} && exec bash${bashArgs ? ` ${bashArgs}` : ""}`;
|
|
225
|
+
if (remote.runtime === "host") return shell;
|
|
226
|
+
if (!remote.container) fail("docker remote is missing its container name.");
|
|
227
|
+
return `docker exec -it ${shellQuote(remote.container)} sh -lc ${shellQuote(shell)}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function testSsh(root: string, remote: Pick<RemoteHost, "host" | "port" | "user">, password: string) {
|
|
231
|
+
const result = await runSsh(root, remote, password, "true", { acceptNewHost: true });
|
|
232
|
+
if (result.code !== 0) fail(`SSH authentication failed: ${result.stderr.trim() || "connection failed"}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function dockerAvailable(root: string, remote: Pick<RemoteHost, "host" | "port" | "user">, password: string) {
|
|
236
|
+
const result = await runSsh(root, remote, password, "command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1", { acceptNewHost: true });
|
|
237
|
+
return result.code === 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function dockerContainers(root: string, remote: Pick<RemoteHost, "host" | "port" | "user">, password: string): Promise<DockerContainer[]> {
|
|
241
|
+
const result = await runSsh(root, remote, password, "docker ps --filter status=running --format '{{.ID}}\\t{{.Names}}\\t{{.Image}}'", { acceptNewHost: true });
|
|
242
|
+
if (result.code !== 0) fail(`could not list Docker containers: ${result.stderr.trim() || "docker command failed"}`);
|
|
243
|
+
return result.stdout.trim().split("\n").filter(Boolean).map((line) => {
|
|
244
|
+
const [id = "", name = "", image = ""] = line.split("\t");
|
|
245
|
+
return { id, name, image };
|
|
246
|
+
}).filter((container) => container.name.toLowerCase().includes("pi"));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function chooseRuntime(dockerIsAvailable: boolean): Promise<RemoteRuntime> {
|
|
250
|
+
if (!dockerIsAvailable) return "host";
|
|
251
|
+
process.stdout.write("\nRuntime:\n 1. Host\n 2. Docker\n");
|
|
252
|
+
while (true) {
|
|
253
|
+
const choice = (await ask("Runtime", "1")).toLowerCase();
|
|
254
|
+
if (choice === "1" || choice === "host") return "host";
|
|
255
|
+
if (choice === "2" || choice === "docker") return "docker";
|
|
256
|
+
process.stderr.write("Enter 1 for Host or 2 for Docker.\n");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function chooseContainer(containers: DockerContainer[]): Promise<string> {
|
|
261
|
+
if (containers.length) {
|
|
262
|
+
process.stdout.write("\nDetected running Pi containers:\n");
|
|
263
|
+
containers.forEach((container, index) => process.stdout.write(` ${index + 1}. ${container.name} ${container.id.slice(0, 12)} ${container.image}\n`));
|
|
264
|
+
}
|
|
265
|
+
const defaultValue = containers.length === 1 ? "1" : undefined;
|
|
266
|
+
while (true) {
|
|
267
|
+
const value = await ask("Container name or number", defaultValue);
|
|
268
|
+
const selected = /^\d+$/.test(value) ? containers[Number(value) - 1]?.name : value;
|
|
269
|
+
if (selected && !/\s/.test(selected)) return selected;
|
|
270
|
+
process.stderr.write("Enter a valid container name or one of the listed numbers.\n");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function verifyPi(root: string, remote: RemoteHost, password: string) {
|
|
275
|
+
const command = remote.runtime === "host"
|
|
276
|
+
? hostPiCommand(remote, ["--version"])
|
|
277
|
+
: `docker exec ${shellQuote(remote.container ?? "")} sh -lc ${shellQuote(hostPiCommand(remote, ["--version"]))}`;
|
|
278
|
+
const result = await runSsh(root, remote, password, command, { acceptNewHost: true });
|
|
279
|
+
if (result.code !== 0) fail(`Pi is not available in the selected ${remote.runtime} runtime: ${result.stderr.trim() || "pi --version failed"}`);
|
|
280
|
+
process.stdout.write(`Pi detected: ${result.stdout.trim()}\n`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function addRemote(root: string, name: string) {
|
|
284
|
+
if (!validName(name)) fail("invalid remote name; use letters, numbers, hyphens, or underscores (max. 64 characters).");
|
|
285
|
+
const store = await readStore(root);
|
|
286
|
+
if (store.remotes[name]) fail(`remote '${name}' already exists.`);
|
|
287
|
+
|
|
288
|
+
const host = await ask("Host"); validateHost(host);
|
|
289
|
+
const portText = await ask("Port", "22");
|
|
290
|
+
const port = Number(portText);
|
|
291
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) fail("port must be an integer between 1 and 65535.");
|
|
292
|
+
const user = await ask("User"); validateUser(user);
|
|
293
|
+
const password = await askSecret("Password (leave empty to use SSH key)");
|
|
294
|
+
const connection = { host, port, user };
|
|
295
|
+
|
|
296
|
+
process.stdout.write("Validating SSH access...\n");
|
|
297
|
+
await testSsh(root, connection, password);
|
|
298
|
+
const hasDocker = await dockerAvailable(root, connection, password);
|
|
299
|
+
if (hasDocker) process.stdout.write("Docker is available on the remote host.\n");
|
|
300
|
+
else process.stdout.write("Docker is not available to the remote user; Host runtime will be used.\n");
|
|
301
|
+
|
|
302
|
+
const runtime = await chooseRuntime(hasDocker);
|
|
303
|
+
const container = runtime === "docker" ? await chooseContainer(await dockerContainers(root, connection, password)) : undefined;
|
|
304
|
+
const piAgentDirectory = await ask("Pi agent directory", "~/.pi/agent"); validatePiAgentDirectory(piAgentDirectory);
|
|
305
|
+
const remote: RemoteHost = { ...connection, runtime, ...(container ? { container } : {}), piAgentDirectory };
|
|
306
|
+
|
|
307
|
+
process.stdout.write(`Validating Pi in the ${runtime} runtime...\n`);
|
|
308
|
+
await verifyPi(root, remote, password);
|
|
309
|
+
await ensureDirectory(root);
|
|
310
|
+
store.remotes[name] = remote;
|
|
311
|
+
await writePrivateJson(storePath(root), store);
|
|
312
|
+
const secrets = await readSecrets(root);
|
|
313
|
+
if (password) secrets.passwords[name] = password;
|
|
314
|
+
else delete secrets.passwords[name];
|
|
315
|
+
await writePrivateJson(secretsPath(root), secrets);
|
|
316
|
+
process.stdout.write(`Remote '${name}' added.\n`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function listRemotes(root: string) {
|
|
320
|
+
const store = await readStore(root);
|
|
321
|
+
const entries = Object.entries(store.remotes).sort(([a], [b]) => a.localeCompare(b));
|
|
322
|
+
if (!entries.length) {
|
|
323
|
+
process.stdout.write("No remotes configured.\n");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
for (const [name, remote] of entries) {
|
|
327
|
+
process.stdout.write(`${name}\t${remote.runtime}\t${remote.user}@${remote.host}:${remote.port}\t${remote.container ?? "-"}\t${remote.piAgentDirectory}\n`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function deleteRemote(root: string, name: string, force: boolean) {
|
|
332
|
+
if (!force) fail("deletion requires --force: pi remote delete <name> --force");
|
|
333
|
+
const store = await readStore(root);
|
|
334
|
+
if (!store.remotes[name]) fail(`remote '${name}' does not exist.`);
|
|
335
|
+
delete store.remotes[name];
|
|
336
|
+
await writePrivateJson(storePath(root), store);
|
|
337
|
+
const secrets = await readSecrets(root);
|
|
338
|
+
delete secrets.passwords[name];
|
|
339
|
+
await writePrivateJson(secretsPath(root), secrets);
|
|
340
|
+
process.stdout.write(`Remote '${name}' deleted.\n`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const profileManagementActions = new Set(["add", "create", "delete", "list", "open", "remove", "resume"]);
|
|
344
|
+
const profileResourceCommands = new Set(["extensions", "packages", "sessions", "skills", "tools"]);
|
|
345
|
+
|
|
346
|
+
function requestedProfile(args: string[]): string | undefined {
|
|
347
|
+
if (args[0] !== "profile" || !args[1] || profileManagementActions.has(args[1].toLowerCase())) return undefined;
|
|
348
|
+
return args[1];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function profileExistsCommand(remote: RemoteHost, profile: string) {
|
|
352
|
+
if (profile === "default") return "true";
|
|
353
|
+
const check = `test -f ${piAgentDirectoryExpression(remote.piAgentDirectory)}/profiles/${shellQuote(profile)}/settings.json`;
|
|
354
|
+
if (remote.runtime === "host") return check;
|
|
355
|
+
if (!remote.container) fail("docker remote is missing its container name.");
|
|
356
|
+
return `docker exec ${shellQuote(remote.container)} sh -lc ${shellQuote(check)}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function connectRemote(root: string, name: string, args: string[]) {
|
|
360
|
+
const store = await readStore(root);
|
|
361
|
+
const remote = store.remotes[name];
|
|
362
|
+
if (!remote) fail(`remote '${name}' does not exist.`);
|
|
363
|
+
const secrets = await readSecrets(root);
|
|
364
|
+
const password = secrets.passwords[name] ?? "";
|
|
365
|
+
const profile = requestedProfile(args);
|
|
366
|
+
if (profile) {
|
|
367
|
+
const exists = await runSsh(root, remote, password, profileExistsCommand(remote, profile));
|
|
368
|
+
if (exists.code !== 0) fail(`profile '${profile}' does not exist on remote '${name}'.`);
|
|
369
|
+
}
|
|
370
|
+
const command = args[0] === "bash"
|
|
371
|
+
? runtimeBashCommand(remote, args.slice(1))
|
|
372
|
+
: profile && profileResourceCommands.has(args[2] ?? "")
|
|
373
|
+
? profileRuntimeCommand(remote, profile, args.slice(2))
|
|
374
|
+
: runtimeCommand(remote, args);
|
|
375
|
+
const result = await runSsh(root, remote, password, command, { interactive: true });
|
|
376
|
+
process.exit(result.code);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export async function handleRemoteCli(args: string[], root: string): Promise<boolean> {
|
|
380
|
+
const remoteTarget = args[0]?.match(/^remote:([a-zA-Z][a-zA-Z0-9_-]{0,63})$/);
|
|
381
|
+
if (remoteTarget) {
|
|
382
|
+
await connectRemote(root, remoteTarget[1], args.slice(1));
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
if (args[0] !== "remote") return false;
|
|
386
|
+
const action = args[1];
|
|
387
|
+
if (action === "add" && args.length === 3) await addRemote(root, args[2]);
|
|
388
|
+
else if (action === "list" && args.length === 2) await listRemotes(root);
|
|
389
|
+
else if (action === "delete" && (args.length === 3 || (args.length === 4 && args[3] === "--force"))) await deleteRemote(root, args[2], args[3] === "--force");
|
|
390
|
+
else fail("usage: pi remote add <name> | pi remote list | pi remote delete <name> --force | pi remote:<name> [pi arguments]");
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { notFound } from "next/navigation";
|
|
2
|
+
import { ConsolePage } from "@/components/console-page";
|
|
3
|
+
const sections = new Set(["chat", "profiles", "settings", "env", "soul", "guardrails", "skills", "tools", "packages", "pulses", "extensions", "applications", "api-server", "pi-console-webui", "skill-sources"]);
|
|
4
|
+
export default async function SectionPage({ params, searchParams }: { params: Promise<{ section: string }>; searchParams: Promise<{ profile?: string }> }) { const { section } = await params; if (!sections.has(section)) notFound(); return <ConsolePage section={section} profile={(await searchParams).profile}/>; }
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { authenticated } from "@/lib/auth";
|
|
3
|
+
import { isAdminTarget, readAdminConfig, writeAdminConfig } from "@/lib/admin-config";
|
|
4
|
+
export async function GET(_: Request, { params }: { params: Promise<{ target: string }> }) { if (!(await authenticated())) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { target } = await params; if (!isAdminTarget(target)) return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json({ config: await readAdminConfig(target) }); }
|
|
5
|
+
export async function PUT(request: Request, { params }: { params: Promise<{ target: string }> }) { if (!(await authenticated())) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { target } = await params; if (!isAdminTarget(target)) return NextResponse.json({ error: "Not found" }, { status: 404 }); try { return NextResponse.json({ config: await writeAdminConfig(target, await request.json()) }); } catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "Invalid configuration" }, { status: 400 }); } }
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { NextResponse } from "next/server";
|
|
3
|
+
import { authenticated } from "@/lib/auth";
|
|
4
|
+
const commands = { "api-server": ["api", "restart"], "pi-console-webui": ["console", "restart"] } as const;
|
|
5
|
+
export async function POST(_: Request, { params }: { params: Promise<{ service: string }> }) { if (!(await authenticated())) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { service } = await params; if (!(service in commands)) return NextResponse.json({ error: "Not found" }, { status: 404 }); const child = spawn("pi", commands[service as keyof typeof commands], { detached: true, stdio: "ignore", env: process.env }); child.unref(); return NextResponse.json({ ok: true }, { status: 202 }); }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { authToken, COOKIE, loginValid } from "@/lib/auth";
|
|
3
|
+
export async function POST(request: Request) {
|
|
4
|
+
const body = await request.json().catch(() => ({}));
|
|
5
|
+
if (typeof body.username !== "string" || typeof body.password !== "string" || !(await loginValid(body.username, body.password))) return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
|
6
|
+
const response = NextResponse.json({ ok: true });
|
|
7
|
+
response.cookies.set(COOKIE, await authToken(), { httpOnly: true, sameSite: "strict", secure: process.env.PI_CONSOLE_HTTPS === "1", path: "/", maxAge: 60 * 60 * 12 });
|
|
8
|
+
return response;
|
|
9
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { getConfig } from "@/lib/config";
|
|
3
|
+
|
|
4
|
+
const hopByHopHeaders = new Set(["connection", "content-length", "host", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
|
|
5
|
+
export async function POST(request: Request, context: { params: Promise<{ slug: string }> }) {
|
|
6
|
+
const { slug } = await context.params; const config = await getConfig();
|
|
7
|
+
const url = new URL(`/api/message/app/${encodeURIComponent(slug)}`, config.apiUrl); url.search = new URL(request.url).search;
|
|
8
|
+
const headers = new Headers(); for (const [name, value] of request.headers) if (!hopByHopHeaders.has(name.toLowerCase())) headers.set(name, value);
|
|
9
|
+
try { const body = await request.arrayBuffer(); const response = await fetch(url, { method: "POST", headers, body: body.byteLength ? body : undefined, cache: "no-store" }); const responseHeaders = new Headers(); for (const name of ["content-type", "cache-control"]) { const value = response.headers.get(name); if (value) responseHeaders.set(name, value); } return new Response(response.body, { status: response.status, headers: responseHeaders }); }
|
|
10
|
+
catch { return NextResponse.json({ error: "Pi API is unavailable." }, { status: 503 }); }
|
|
11
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { authenticated } from "@/lib/auth";
|
|
3
|
+
import { getConfig } from "@/lib/config";
|
|
4
|
+
|
|
5
|
+
async function proxy(request: Request, context: { params: Promise<{ path: string[] }> }) {
|
|
6
|
+
if (!(await authenticated())) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
7
|
+
const { path } = await context.params;
|
|
8
|
+
const config = await getConfig();
|
|
9
|
+
if (!config.apiToken) return NextResponse.json({ error: "Pi API token is not configured" }, { status: 503 });
|
|
10
|
+
const prefix = path[0] === "profile" ? "/" : "/api/";
|
|
11
|
+
const url = new URL(`${prefix}${path.map(encodeURIComponent).join("/")}`, config.apiUrl);
|
|
12
|
+
url.search = new URL(request.url).search;
|
|
13
|
+
const headers = new Headers({ authorization: `Bearer ${config.apiToken}` });
|
|
14
|
+
const contentType = request.headers.get("content-type");
|
|
15
|
+
const method = request.method;
|
|
16
|
+
try {
|
|
17
|
+
const payload = ["GET", "HEAD"].includes(method) ? undefined : await request.arrayBuffer();
|
|
18
|
+
if (contentType && payload && payload.byteLength > 0) headers.set("content-type", contentType);
|
|
19
|
+
const response = await fetch(url, { method, headers, body: payload && payload.byteLength > 0 ? payload : undefined, cache: "no-store" });
|
|
20
|
+
const responseHeaders = new Headers();
|
|
21
|
+
for (const name of ["content-type", "cache-control"]) { const value = response.headers.get(name); if (value) responseHeaders.set(name, value); }
|
|
22
|
+
return new Response(response.body, { status: response.status, headers: responseHeaders });
|
|
23
|
+
} catch {
|
|
24
|
+
return NextResponse.json({ error: "Pi API is unavailable. Start it with: pi api start" }, { status: 503 });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export const GET = proxy;
|
|
28
|
+
export const POST = proxy;
|
|
29
|
+
export const PUT = proxy;
|
|
30
|
+
export const PATCH = proxy;
|
|
31
|
+
export const DELETE = proxy;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
:root { color-scheme: light; }
|
|
6
|
+
* { box-sizing: border-box; }
|
|
7
|
+
body { margin: 0; min-height: 100vh; background: #f8fafc; color: #18181b; font-family: Arial, sans-serif; }
|
|
8
|
+
body:has(.terminal-page) { background: #0d1117; }
|
|
9
|
+
.terminal-page { background: #0d1117 !important; color: #e6edf3 !important; }
|
|
10
|
+
.terminal-header { border-bottom: 1px solid #30363d; background: #161b22; color: #e6edf3; }
|
|
11
|
+
.terminal-header span:last-child { color: #8b949e; }
|
|
12
|
+
.terminal-screen { position: relative; min-width: 0; overflow: hidden; background: #0d1117; }
|
|
13
|
+
.terminal-host { position: absolute; inset: 16px 16px 28px; min-width: 0; min-height: 0; overflow: hidden; }
|
|
14
|
+
.terminal-host .xterm { width: 100%; max-width: 100%; height: 100%; overflow: hidden; }
|
|
15
|
+
.terminal-host .xterm-viewport { background-color: #0d1117 !important; }
|
|
16
|
+
button, input, textarea, select { font: inherit; }
|
|
17
|
+
select, select option { background-color: #ffffff !important; }
|
|
18
|
+
.bg-zinc-950, .bg-zinc-900 { background-color: #ffffff !important; }
|
|
19
|
+
.bg-zinc-800 { background-color: #e4e4e7 !important; }
|
|
20
|
+
.border-zinc-800, .border-zinc-700 { border-color: #d4d4d8 !important; }
|
|
21
|
+
.text-zinc-400, .text-zinc-300 { color: #52525b !important; }
|
|
22
|
+
.text-amber-300 { color: #a16207 !important; }
|
|
23
|
+
.bg-red-950 { background-color: #fef2f2 !important; }
|
|
24
|
+
.text-red-300 { color: #b91c1c !important; }
|
|
25
|
+
.log-json { margin: 0; max-height: 320px; overflow: auto; white-space: pre-wrap; border: 1px solid #d4d4d8; border-radius: .5rem; background: #f4f4f5 !important; color: #18181b !important; padding: .75rem; font-size: .75rem; line-height: 1.35; }
|
|
26
|
+
.log-hover-json { border-color: #b9e6e8; background: rgb(246 253 255 / .88) !important; }
|
|
27
|
+
.handler-test-logs { margin: 1rem 0 0; min-height: 7rem; overflow: auto; border: 1px solid #d4d4d8; border-radius: .5rem; background: #ffffff !important; color: #18181b !important; padding: 1rem; font-size: .75rem; line-height: 1.5; white-space: pre-wrap; }
|
|
28
|
+
.json-view .cm-editor { max-height: 320px; overflow: auto; background: #fff; font-size: .75rem; }
|
|
29
|
+
.json-view .cm-scroller { overflow: auto; }
|
|
30
|
+
.json-view .cm-gutters { display: none; }
|
|
31
|
+
.json-view .cm-content { padding: .75rem; }
|
|
32
|
+
.json-view .cm-activeLine, .json-view .cm-activeLineGutter { background-color: transparent !important; }
|
|
33
|
+
.skill-markdown { overflow-x: auto; }
|
|
34
|
+
.skill-markdown table { width: 100%; min-width: 860px; border-collapse: separate; border-spacing: 0; overflow: hidden; border: 1px solid #e4e4e7; border-radius: .625rem; font-size: .875rem; line-height: 1.5; }
|
|
35
|
+
.skill-markdown thead { background: #f4f4f5; }
|
|
36
|
+
.skill-markdown th { padding: .75rem 1rem; border-bottom: 1px solid #d4d4d8; color: #3f3f46; font-weight: 650; text-align: left; vertical-align: bottom; }
|
|
37
|
+
.skill-markdown td { padding: .75rem 1rem; border-bottom: 1px solid #e4e4e7; color: #27272a; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
|
|
38
|
+
.skill-markdown tbody tr:nth-child(even) { background: #fafafa; }
|
|
39
|
+
.skill-markdown tbody tr:last-child td { border-bottom: 0; }
|
|
40
|
+
.skill-markdown th:not(:last-child), .skill-markdown td:not(:last-child) { border-right: 1px solid #e4e4e7; }
|
|
41
|
+
.skill-markdown table code { white-space: nowrap; font-size: .8em; }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#406889"/><text x="32" y="45" text-anchor="middle" font-family="Georgia,serif" font-size="48" font-weight="bold" fill="white">π</text></svg>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import "./globals.css";
|
|
2
|
+
import { ToastProvider } from "@/components/toast";
|
|
3
|
+
import { ConsoleStateProvider } from "@/components/console-state";
|
|
4
|
+
export const metadata = { title: "Pi Console", description: "Pi API console" };
|
|
5
|
+
export default function Layout({ children }: Readonly<{ children: React.ReactNode }>) { return <html lang="pt-BR"><body><ToastProvider><ConsoleStateProvider>{children}</ConsoleStateProvider></ToastProvider></body></html>; }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Input } from "@/components/ui/input";
|
|
6
|
+
import { Loader2 } from "lucide-react";
|
|
7
|
+
export default function Login() {
|
|
8
|
+
const router = useRouter(); const [username, setUsername] = useState(""), [password, setPassword] = useState(""), [error, setError] = useState(""), [loading, setLoading] = useState(false);
|
|
9
|
+
async function submit(event: React.FormEvent) { event.preventDefault(); if (loading) return; setError(""); setLoading(true); try { const response = await fetch("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ username, password }) }); if (response.ok) router.push("/"); else { setError("Invalid credentials."); window.setTimeout(() => setError(""), 4_000); } } catch { setError("Unable to sign in."); window.setTimeout(() => setError(""), 4_000); } finally { setLoading(false); } }
|
|
10
|
+
return <main className="grid min-h-screen place-items-center p-4"><form onSubmit={submit} className="w-full max-w-sm space-y-4 rounded-xl border border-zinc-800 bg-zinc-950 p-6 shadow-xl"><h1 className="text-2xl font-bold">Pi Console</h1><p className="text-sm text-zinc-400">Sign in to manage Pi.</p><Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Username" autoFocus /><Input value={password} onChange={(e) => setPassword(e.target.value)} type="password" placeholder="Password" />{error && <p className="text-sm text-red-400">{error}</p>}<Button className="w-full" disabled={loading}>{loading ? <><Loader2 className="mr-2 animate-spin" size={16}/>Signing in…</> : "Sign in"}</Button></form></main>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
2
|
+
import { authenticated } from "@/lib/auth";
|
|
3
|
+
import { TerminalClient } from "@/components/terminal-client";
|
|
4
|
+
export default async function TerminalPage() { if (!(await authenticated())) redirect("/login"); return <TerminalClient/>; }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Loader2, RotateCw, Save } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { useToast } from "@/components/toast";
|
|
8
|
+
|
|
9
|
+
type Target = "api-server" | "pi-console-webui";
|
|
10
|
+
export function AdminConfigForm({ target, initial }: { target: Target; initial?: Record<string, unknown> }) {
|
|
11
|
+
const { toast } = useToast(); const api = target === "api-server";
|
|
12
|
+
const [host, setHost] = useState(String(initial?.host ?? (api ? "0.0.0.0" : "127.0.0.1"))); const [port, setPort] = useState(String(initial?.port ?? (api ? 8767 : 3030))); const [username, setUsername] = useState(String(initial?.username ?? "admin")); const [password, setPassword] = useState(String(initial?.password ?? "")); const [secret, setSecret] = useState(String(initial?.secret ?? "")); const [corsOrigin, setCorsOrigin] = useState(String((initial?.cors as { origin?: string } | undefined)?.origin ?? "*")); const [apiToken, setApiToken] = useState(""); const [saving, setSaving] = useState(false), [restarting, setRestarting] = useState(false);
|
|
13
|
+
const save = async () => { if (saving) return; try { setSaving(true); const config: Record<string, unknown> = api ? { host, port: Number(port), cors: { origin: corsOrigin } } : { host, port: Number(port), username, password, secret }; if (apiToken) config.apiToken = apiToken; const response = await fetch(`/api/admin/config/${target}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(config) }); if (!response.ok) throw new Error((await response.json()).error ?? "Unable to save configuration"); toast("Configuration saved."); } catch (cause) { toast((cause as Error).message, "error"); } finally { setSaving(false); } };
|
|
14
|
+
const restart = async () => { if (restarting) return; try { setRestarting(true); const response = await fetch(`/api/admin/services/${target}/restart`, { method: "POST" }); if (!response.ok) throw new Error("Unable to restart service"); toast("Service restart requested."); } catch (cause) { toast((cause as Error).message, "error"); } finally { setRestarting(false); } };
|
|
15
|
+
return <Card><div className="flex items-center justify-between gap-3"><div><CardTitle>{api ? "API Server" : "Pi Console WebUI"}</CardTitle><p className="text-sm text-zinc-500">Update service settings and restart to apply changes.</p></div><div className="flex gap-2"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Restart service" disabled={restarting} onClick={restart}>{restarting ? <Loader2 className="animate-spin" size={17}/> : <RotateCw size={17}/>}</Button><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save configuration" disabled={saving} onClick={save}>{saving ? <Loader2 className="animate-spin" size={17}/> : <Save size={17}/>}</Button></div></div><div className="mt-5 grid gap-4 md:grid-cols-2"><label className="text-sm font-medium">Host<Input className="mt-1" value={host} onChange={(event) => setHost(event.target.value)}/></label><label className="text-sm font-medium">Port<Input className="mt-1" type="number" min="1" max="65535" value={port} onChange={(event) => setPort(event.target.value)}/></label>{api ? <><label className="text-sm font-medium md:col-span-2">CORS origin<Input className="mt-1" value={corsOrigin} onChange={(event) => setCorsOrigin(event.target.value)}/></label><label className="text-sm font-medium md:col-span-2">Replace API token <span className="font-normal text-zinc-500">(leave blank to keep current token)</span><Input className="mt-1" type="password" value={apiToken} onChange={(event) => setApiToken(event.target.value)} placeholder="New API token"/></label></> : <><label className="text-sm font-medium">Username<Input className="mt-1" value={username} onChange={(event) => setUsername(event.target.value)}/></label><label className="text-sm font-medium">Password<Input className="mt-1" type="password" value={password} onChange={(event) => setPassword(event.target.value)}/></label><label className="text-sm font-medium md:col-span-2">Session secret<Input className="mt-1" value={secret} onChange={(event) => setSecret(event.target.value)}/></label></>}</div></Card>;
|
|
16
|
+
}
|