Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d6067650a | |||
| 5bc3f7d34a | |||
| b058179177 | |||
| b6b40b0c1b | |||
| 758609775f |
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.8.4 (2026-10-15) — tmux-member (member tmux follows upgrades), Users → Members' tmux._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
_Last synced: v2.8.6 (2026-10-15) — Users page is now Administration (`/admin`: Users + Server tabs, `GET /api/host` staff-only, read-only per-user CPU/memory)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
@@ -56,7 +56,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
|
||||
- **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it.
|
||||
- **Removing a lane deletes the lane's RECORD and nothing else — for every kind, managed included.** `POST /api/lanes/:id/remove` stops the run and the stack, clears AgentHub's own runtime bookkeeping under `LANES_ROOT/.state/lane<slot>/`, and deletes the row. It calls neither `removeWorktree` nor `removeLaneData`: the working tree, its branch and the lane's database are the USER's data, and a mis-click on a dashboard row may never cost a branch's unpushed code. Deleting those is the user's own `git worktree remove` / `git branch -d` / db drop. Consequently `remove` has no unpushed-commits force-gate (only `reset` does) and no preflight blocker stops it. Do not add a "also delete the worktree" flag back onto this route. (A session's own `claude --worktree` tree is a different thing with its own guarded route — see the session-worktree rule below; a lane's folder is never it.)
|
||||
- **A lane can hold up to ten tmux sessions (slots 0–9; the Workspace groups them into tabs of up to four split panes) — and only slot 0 is the lane's run.** `agenthub-lane-<id>` is slot 0 and the only one `run_id`/`status` ever record; `agenthub-lane-<id>-<1-9>` are extra sessions on the same lane (`MAX_PANE_SLOT` in `server/lib/pty-run.js`; the `-[1-9]` run id pattern is repeated in `pty-attach.js`, `websocket.js`, `routes/hooks.js` and the client — widen them together). Anything that must stop a lane's agents (`stop`, `reset`, `remove`) goes through `laneRunIds` so no slot survives it; anything that names "the lane's pane" (`agenthub lanes shell`) means slot 0. A client that wants "a new tab" sends `slot: "auto"` and the SERVER picks the first free slot under the lane lock (`409 ERUNLIVE` when all ten are busy) — never a slot computed from the client's own list, which may already be stale.
|
||||
- **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `<data dir>/trash/<user id>/`), restorable 7 days. `write-as` is root-installed: a change to it needs `sudo agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`.
|
||||
- **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `<data dir>/trash/<user id>/`), restorable 7 days. `write-as` is root-installed: a change to it needs `agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `<data dir>/trash/<user id>/`, items tagged `store`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`.
|
||||
- **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo.
|
||||
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions.
|
||||
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into the CALLER's `~/.claude/skills/` (S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
|
||||
|
||||
+58
-6
@@ -3934,21 +3934,73 @@ async function cmdServer(sub, flags) {
|
||||
} else {
|
||||
console.log(c.dim(" an admin already exists — add more with `agenthub users add`"));
|
||||
}
|
||||
const wasServer = auth.isServerMode();
|
||||
settingsEnv.writeSetting("AGENTHUB_MODE", "server");
|
||||
envNote("AGENTHUB_MODE");
|
||||
console.log(`${c.green("✔")} server mode on (${file})`);
|
||||
// One Linux user per account (S3) is the default on Linux: install it now
|
||||
// (one sudo), unless it is already in place or `--no-system` opts out.
|
||||
const linux = require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js"));
|
||||
const shared = flags["no-system"] === true || process.platform !== "linux";
|
||||
if (!shared) {
|
||||
if (linux.serverConf()) {
|
||||
console.log(c.dim(" one Linux user per account is already installed"));
|
||||
// The system unit serves; it reads the mode only when it starts.
|
||||
if (!wasServer) {
|
||||
const r = spawnSync("sudo", [
|
||||
"-n",
|
||||
"systemctl",
|
||||
"restart",
|
||||
"--no-block",
|
||||
"agenthub.service",
|
||||
]);
|
||||
console.log(
|
||||
r.status === 0
|
||||
? `${c.green("✔")} agenthub.service restarted`
|
||||
: c.yellow("! restart it: sudo systemctl restart agenthub")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(" Installing one Linux user per account (asks for your sudo password)…");
|
||||
try {
|
||||
await installSystem({}, settingsEnv);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`${e.message}\n Server mode is on, but every pane still runs as this account.` +
|
||||
"\n Fix it and run `agenthub server install-system`, or keep one shared" +
|
||||
" account with `agenthub server setup --no-system`."
|
||||
);
|
||||
}
|
||||
}
|
||||
// The admin made above (and anyone made before) gets their Linux account.
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
await cmdUsers("provision", [], { all: true });
|
||||
}
|
||||
console.log(" Next:");
|
||||
console.log(
|
||||
" 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
|
||||
shared
|
||||
? " 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
|
||||
: " 1. Log out and back in once, so your shell joins the agenthub groups"
|
||||
);
|
||||
console.log(
|
||||
" 2. Serve it over HTTPS through a reverse proxy that keeps the Host header,\n" +
|
||||
" and run setup with --hostname <that name> — see docs/SERVER-MODE.md."
|
||||
);
|
||||
console.log(
|
||||
c.dim(" Members see only their own workspaces, but every pane still runs as this")
|
||||
);
|
||||
console.log(c.dim(" one Linux account — use it with people you trust (docs/SERVER-MODE.md)."));
|
||||
if (shared) {
|
||||
console.log(
|
||||
c.yellow(
|
||||
process.platform === "linux"
|
||||
? "! --no-system: every pane runs as this one Linux account"
|
||||
: "! one Linux user per account needs Linux: every pane runs as this one account"
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
c.dim(" Members see only their own workspaces, but an agent in a pane can read")
|
||||
);
|
||||
console.log(
|
||||
c.dim(" every user's files — use it with people you trust (docs/SERVER-MODE.md).")
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
c.dim(" Workspaces made before now have no owner: agenthub users assign <id> <user>")
|
||||
);
|
||||
@@ -3989,7 +4041,7 @@ async function cmdServer(sub, flags) {
|
||||
}
|
||||
console.error(
|
||||
c.red(
|
||||
"✖ Usage: agenthub server setup [--hostname name] [--admin name] | install-system [--dry-run|--uninstall] | tools | off | status"
|
||||
"✖ Usage: agenthub server setup [--hostname name] [--admin name] [--no-system] | install-system [--dry-run|--uninstall] | tools | off | status"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
|
||||
+7
-3
@@ -89,7 +89,9 @@ const SnapshotView = lazy(() =>
|
||||
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
||||
);
|
||||
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
|
||||
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
|
||||
const Administration = lazy(() =>
|
||||
import("./pages/Administration").then((m) => ({ default: m.Administration }))
|
||||
);
|
||||
const Artifacts = lazy(() => import("./pages/Artifacts").then((m) => ({ default: m.Artifacts })));
|
||||
|
||||
function PageFallback() {
|
||||
@@ -237,13 +239,15 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="users"
|
||||
path="admin"
|
||||
element={
|
||||
<Page>
|
||||
<Users />
|
||||
<Administration />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
{/* The Users page became Administration's first tab. */}
|
||||
<Route path="users" element={<Navigate to="/admin" replace />} />
|
||||
<Route
|
||||
path="settings/*"
|
||||
element={
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
ChevronDown,
|
||||
FolderTree,
|
||||
LogOut,
|
||||
UsersRound,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -479,11 +479,11 @@ export function Sidebar({
|
||||
{[
|
||||
// The file manager serves a shared server; locally it is the desktop's.
|
||||
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
|
||||
// Server mode: the Users page, for an admin or analyst.
|
||||
// Server mode: Administration (users, server), for an admin or analyst.
|
||||
...(auth.state.mode === "server" &&
|
||||
auth.state.user &&
|
||||
auth.state.user.role !== "developer"
|
||||
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
|
||||
? [{ to: "/admin", icon: ShieldCheck, key: "nav:admin" }]
|
||||
: []),
|
||||
].map(({ to, icon: Icon, key }) => {
|
||||
const label = t(key);
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* @file ServerPanel.tsx
|
||||
* @description The Administration page's Server tab: the machine AgentHub
|
||||
* runs on — hostname, OS, uptime, CPU and its load, memory and swap, every
|
||||
* real disk (HDD/SSD, how full), any NVIDIA GPU — and each user's live share
|
||||
* of it (`GET /api/host`, server/lib/host-stats.js): CPU % of the whole
|
||||
* machine, resident memory, process count, the busiest processes by name, and
|
||||
* the disk their home takes (`GET /api/users/storage`, as a share of the disk
|
||||
* that holds the homes). Re-read every `POLL_MS` while shown; Refresh re-reads
|
||||
* at once. Read-only — the only action here is the members' tmux card
|
||||
* (`MemberTmuxCard`, an admin's restart).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
MonitorCog,
|
||||
RefreshCw,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
type ServerUserUsage,
|
||||
type UserStorage,
|
||||
} from "../../lib/api";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { StatCard } from "../StatCard";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { MemberTmuxCard } from "../users/MemberTmuxCard";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
|
||||
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
/** A thin bar, amber past 75 %, red past 90 %. */
|
||||
function Meter({ value, label }: { value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
||||
return (
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return d > 0 ? t("server.uptimeDays", { d, h }) : t("server.uptimeHours", { h, m });
|
||||
}
|
||||
|
||||
export function ServerPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("admin");
|
||||
const [stats, setStats] = useState<HostStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
||||
const [open, setOpen] = useState<Set<string>>(new Set());
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.stats()
|
||||
.then((s) => {
|
||||
setStats(s);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// Names, pictures and home sizes: read once, they change slowly.
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch(() => setUsers([]));
|
||||
api.users
|
||||
.storage()
|
||||
.then((r) => setStorage(r.storage))
|
||||
.catch(() => setStorage({}));
|
||||
}, []);
|
||||
|
||||
const byId = new Map((users ?? []).map((u) => [u.id, u]));
|
||||
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
|
||||
const loading = !stats && !error;
|
||||
const mem = stats?.memory;
|
||||
const toggle = (key: string) =>
|
||||
setOpen((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(key)) n.delete(key);
|
||||
else n.add(key);
|
||||
return n;
|
||||
});
|
||||
|
||||
const groupName = (g: HostUsageGroup) => {
|
||||
if (g.kind === "host") return t("server.hostGroup");
|
||||
if (g.kind === "system") return t("server.systemGroup");
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
return u?.displayName || g.username || "";
|
||||
};
|
||||
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="server-panel">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<Server className="w-4 h-4 text-accent" />
|
||||
<span className="truncate">{stats?.hostname ?? t("server.title")}</span>
|
||||
</h2>
|
||||
{stats && (
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")}
|
||||
{" · "}
|
||||
{t("server.uptime", { v: uptimeText(t, stats.uptime) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||
{t("common:refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">{error}</span>
|
||||
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label={t("server.cpu")}
|
||||
value={stats?.usage ? fmtPct(stats.usage.cpu) : "—"}
|
||||
icon={Cpu}
|
||||
trend={
|
||||
stats
|
||||
? t("server.cores", {
|
||||
n: stats.cpu.cores,
|
||||
load: stats.cpu.load.map((l) => l.toFixed(2)).join(" "),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
raw={stats?.cpu.model ?? undefined}
|
||||
help={t("server.cpuHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.memory")}
|
||||
value={mem ? fmtPct(pct(mem.used, mem.total)) : "—"}
|
||||
icon={MemoryStick}
|
||||
trend={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.swap")}
|
||||
value={mem && mem.swapTotal ? fmtPct(pct(mem.swapUsed, mem.swapTotal)) : "—"}
|
||||
icon={MonitorCog}
|
||||
trend={mem ? `${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.homesDisk")}
|
||||
value={homesDisk ? fmtPct(pct(homesDisk.used, homesDisk.total)) : "—"}
|
||||
icon={HardDrive}
|
||||
trend={
|
||||
homesDisk
|
||||
? `${formatBytes(homesDisk.free)} ${t("server.free")} · ${homesDisk.mount}`
|
||||
: undefined
|
||||
}
|
||||
help={t("server.homesDiskHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<div className="px-5 pt-4 pb-2">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.usageTitle")}</h3>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}
|
||||
</p>
|
||||
</div>
|
||||
{stats && !stats.usage ? (
|
||||
<p className="px-5 pb-4 text-sm text-fg-muted">{t("server.usageUnavailable")}</p>
|
||||
) : (
|
||||
<table className="w-full min-w-[760px]" data-testid="server-usage-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.who")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.cpu")}</th>
|
||||
<th className={`${th} w-[22%]`}>{t("server.memory")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.processes")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.home")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-6 text-sm text-fg-muted">
|
||||
{t("server.sampling")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{stats?.usage?.groups.map((g) => {
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
const home = g.userId != null && storage ? storage[g.userId] : undefined;
|
||||
const isOpen = open.has(g.key);
|
||||
const name = groupName(g);
|
||||
return (
|
||||
<Fragment key={g.key}>
|
||||
<tr className="hover:bg-surface-4/40 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
{g.top.length ? (
|
||||
isOpen ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-fg-muted" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
{g.kind === "user" ? (
|
||||
<UserAvatar
|
||||
name={name}
|
||||
avatarAt={u?.avatarAt}
|
||||
userId={g.userId}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-4">
|
||||
<Server className="w-3.5 h-3.5 text-fg-muted" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm text-fg-primary truncate">{name}</span>
|
||||
{g.kind === "user" && u?.displayName && (
|
||||
<span className="block text-[11px] text-fg-muted">{g.username}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">{fmtPct(g.cpu)}</div>
|
||||
<Meter value={g.cpu} label={`${name} ${t("server.cpu")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(g.memory)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(g.memoryPercent)})</span>
|
||||
</div>
|
||||
<Meter value={g.memoryPercent} label={`${name} ${t("server.memory")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{g.processes}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
{home ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(home.bytes)}
|
||||
{homesDisk && (
|
||||
<span className="text-fg-muted">
|
||||
{" "}
|
||||
({fmtPct(pct(home.bytes, homesDisk.total))})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{homesDisk && (
|
||||
<Meter
|
||||
value={pct(home.bytes, homesDisk.total)}
|
||||
label={`${name} ${t("server.home")}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-fg-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen &&
|
||||
g.top.map((p) => (
|
||||
<tr key={`${g.key}:${p.pid}`} className="bg-surface-2/40">
|
||||
<td className="pl-14 pr-5 py-1.5 text-xs text-fg-secondary font-mono truncate">
|
||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{fmtPct(p.cpu)}
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{formatBytes(p.memory)}
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats && stats.disks.length > 0 && (
|
||||
<div className="card overflow-x-auto">
|
||||
<h3 className="px-5 pt-4 pb-2 text-sm font-semibold text-fg-primary">
|
||||
{t("server.disks")}
|
||||
</h3>
|
||||
<table className="w-full min-w-[640px]" data-testid="server-disks-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.mount")}</th>
|
||||
<th className={th}>{t("server.device")}</th>
|
||||
<th className={`${th} w-[30%]`}>{t("server.used")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.free")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{stats.disks.map((d) => (
|
||||
<tr key={d.device}>
|
||||
<td className="px-5 py-3 text-sm text-fg-primary">
|
||||
<span className="font-mono">{d.mount}</span>
|
||||
{d.mount === stats.homesMount && (
|
||||
<span className="badge ml-2 py-0 text-[10px]">{t("server.homesBadge")}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-fg-secondary">
|
||||
<span className="font-mono">{d.device}</span> · {d.type}
|
||||
{d.kind && <span className="badge ml-2 py-0 text-[10px]">{d.kind}</span>}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(d.used)} / {formatBytes(d.total)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(pct(d.used, d.total))})</span>
|
||||
</div>
|
||||
<Meter value={pct(d.used, d.total)} label={d.mount} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{formatBytes(d.free)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && stats.gpus.length > 0 && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.gpus")}</h3>
|
||||
{stats.gpus.map((g, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-fg-primary">{g.name}</span>
|
||||
<span className="text-fg-muted tabular-nums">
|
||||
{fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "}
|
||||
{formatBytes(g.memoryTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<Meter value={g.utilization} label={g.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MemberTmuxCard isAdmin={isAdmin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,12 @@
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
|
||||
* drag items onto a folder to move them (Ctrl: copy); download a file as
|
||||
* itself, anything else as one zip; a taken name asks Replace / Keep both /
|
||||
* Skip. Server side: `/api/lanes/:id/fs/*` (server/lib/file-explorer.js).
|
||||
* Skip. Deleting forever asks first when it empties the Trash or touches
|
||||
* another user's. An admin's Trash opens on one folder per user (with its
|
||||
* item count); each folder is THAT user's Trash, to restore from or purge
|
||||
* without waiting the 7 days.
|
||||
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
|
||||
* (server/lib/file-explorer.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -82,6 +87,7 @@ import {
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
@@ -96,6 +102,11 @@ type Clip = { place: FsPlace; paths: string[]; cut: boolean };
|
||||
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
|
||||
let clipboard: Clip | null = null;
|
||||
|
||||
/** A menu item's handler that closes its menu first. */
|
||||
const pick = (m: { close: () => void }, fn: () => void) => () => {
|
||||
m.close();
|
||||
fn();
|
||||
};
|
||||
/** The workspace id of a place, or null for a home. */
|
||||
const laneOf = (t: FsPlace) => (isHomePlace(t) ? null : typeof t === "number" ? t : t.lane);
|
||||
/** A workspace's own folder (not a session worktree or an extra folder), or a home. */
|
||||
@@ -250,6 +261,10 @@ export function FileBrowser({
|
||||
const [showHidden, setShowHidden] = useState(() => readPref(HIDDEN_KEY, "0") === "1");
|
||||
const [query, setQuery] = useState<string | null>(null);
|
||||
const [trash, setTrash] = useState<FsTrashItem[]>([]);
|
||||
// An admin's Trash opens on its root: one folder per user's Trash, counted
|
||||
// from `GET /api/homes/trash` (a Trash that could not be read: its error).
|
||||
const [trashRoot, setTrashRoot] = useState(false);
|
||||
const [trashErrors, setTrashErrors] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
|
||||
const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
|
||||
@@ -269,6 +284,9 @@ export function FileBrowser({
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const folderInput = useRef<HTMLInputElement>(null);
|
||||
const ctx = useContextMenu<FsEntry | null>();
|
||||
// The sidebar's places and the path bar's crumbs have their own menus.
|
||||
const placeCtx = useContextMenu<{ home: string } | { lane: Lane }>();
|
||||
const crumbCtx = useContextMenu<string>();
|
||||
|
||||
// A new caller target (another workspace's Files tab, the page's URL) —
|
||||
// not the echo of our own `go()` through the page's URL, which would wipe
|
||||
@@ -330,7 +348,12 @@ export function FileBrowser({
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
if (view === "trash") setTrash((await api.files.trash(target)).items);
|
||||
if (view === "trash" && trashRoot) {
|
||||
const out = await api.files.allTrash();
|
||||
setTrash(out.items);
|
||||
// A user whose trash could not be read is said so on their folder.
|
||||
setTrashErrors(Object.fromEntries((out.errors ?? []).map((e) => [e.home, e.message])));
|
||||
} else if (view === "trash") setTrash((await api.files.trash(target)).items);
|
||||
else setListing(await api.files.list(target, path));
|
||||
} catch (e) {
|
||||
// A dashboard that does not know /fs yet (not restarted) answers 404.
|
||||
@@ -338,10 +361,11 @@ export function FileBrowser({
|
||||
(e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
|
||||
);
|
||||
if (view === "files" && errCode(e) === "ENOFILE" && path) setPathState("");
|
||||
if (view === "trash") setTrash([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [target, path, view, t]);
|
||||
}, [target, path, view, trashRoot, t]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -635,11 +659,82 @@ export function FileBrowser({
|
||||
|
||||
const homeName = (who: string) =>
|
||||
who === "me" ? t("fx.myHome") : (homes.find((h) => h.id === who)?.label ?? who);
|
||||
/** Delete forever — asked first when it is all of it or someone else's. */
|
||||
const purge = async (id: string) => {
|
||||
if (id === "all" || ro) {
|
||||
const item = trash.find((i) => i.id === id);
|
||||
const ok = await confirmDialog({
|
||||
title: id === "all" ? t("fx.emptyTrashTitle") : t("fx.purgeTitle"),
|
||||
message:
|
||||
id === "all"
|
||||
? t("fx.emptyTrashBody", { count: trash.length, where: trashLabel })
|
||||
: t("fx.purgeBody", { name: item?.path ?? id, where: trashLabel }),
|
||||
confirmLabel: id === "all" ? t("fx.emptyTrash") : t("fx.purge"),
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
const item = trash.find((i) => i.id === id);
|
||||
await run(() => api.files.purge(target, id, item?.store), t("fx.purged"));
|
||||
};
|
||||
/** Empty one user's whole Trash from the Trash root — asked first. */
|
||||
const emptyTrashOf = async (home: string, count: number) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("fx.emptyTrashTitle"),
|
||||
message: t("fx.emptyTrashBody", {
|
||||
count,
|
||||
where: home === "me" ? t("fx.myTrash") : t("fx.trashOf", { name: homeName(home) }),
|
||||
}),
|
||||
confirmLabel: t("fx.emptyTrash"),
|
||||
});
|
||||
if (ok) await run(() => api.files.purge({ home }, "all"), t("fx.purged"));
|
||||
};
|
||||
const restoreItem = (id: string) => {
|
||||
const item = trash.find((i) => i.id === id);
|
||||
void run(() => api.files.restore(target, id, item?.store), t("fx.restored"));
|
||||
};
|
||||
/**
|
||||
* Open a Trash. Every user has ONE recycle bin — their home's, over every
|
||||
* place they deleted in — so `home` opens that user's ("me": the caller's);
|
||||
* null (no home to open) falls back to the trash of the place shown, and
|
||||
* "*" opens the root an admin starts on: one folder per user's Trash.
|
||||
*/
|
||||
const openTrash = (home: string | null) => {
|
||||
if (home === "*") {
|
||||
setTrashRoot(true);
|
||||
setView("trash");
|
||||
setSelected(new Set());
|
||||
return;
|
||||
}
|
||||
setTrashRoot(false);
|
||||
if (home && !(isHomePlace(target) && target.home === home)) {
|
||||
setTarget({ home });
|
||||
setPathState("");
|
||||
setHistory({ back: [], fwd: [] });
|
||||
onNavigate?.({ home }, "");
|
||||
}
|
||||
setView("trash");
|
||||
setSelected(new Set());
|
||||
};
|
||||
// Someone else's Trash can be opened: the Trash starts on its folders.
|
||||
const manyTrashes = homes.some((h) => h.id !== "me");
|
||||
const openTrashes = () =>
|
||||
openTrash(manyTrashes ? "*" : homes.some((h) => h.id === "me") ? "me" : null);
|
||||
const placeName = (p: FsPlace) => (isHomePlace(p) ? homeName(p.home) : laneName(laneOf(p)!));
|
||||
const laneName = (id: number) => {
|
||||
const l = lanes.find((x) => x.id === id);
|
||||
return l ? l.title || l.cwd.split("/").pop() || `#${id}` : `#${id}`;
|
||||
};
|
||||
// A home's Trash is its USER's recycle bin, named after them: another
|
||||
// user's shows THAT user's (an admin may restore from or purge it; the rest
|
||||
// of the home stays read-only). A workspace's (no home to open) is the
|
||||
// trash of whoever owns its folder.
|
||||
const trashLabel = trashRoot
|
||||
? t("fx.trash")
|
||||
: !isHomePlace(target)
|
||||
? t("fx.trashAt", { name: laneName(laneOf(target)!) })
|
||||
: target.home === "me"
|
||||
? t("fx.myTrash")
|
||||
: t("fx.trashOf", { name: homeName(target.home) });
|
||||
const crumbs = path ? path.split("/") : [];
|
||||
const menuItem = ctx.menu?.item ?? null;
|
||||
const menuTargets = menuItem ? (selected.has(menuItem.path) ? selectedEntries : [menuItem]) : [];
|
||||
@@ -697,6 +792,7 @@ export function FileBrowser({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go("", { home: "me" })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === "me"
|
||||
)}
|
||||
@@ -719,6 +815,7 @@ export function FileBrowser({
|
||||
type="button"
|
||||
title={t("fx.readOnlyHome")}
|
||||
onClick={() => go("", { home: h.id })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === h.id
|
||||
)}
|
||||
@@ -739,6 +836,7 @@ export function FileBrowser({
|
||||
type="button"
|
||||
title={l.cwd}
|
||||
onClick={() => go("", l.id)}
|
||||
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
|
||||
className={placeClass(
|
||||
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
||||
)}
|
||||
@@ -748,17 +846,10 @@ export function FileBrowser({
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-border p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setView("trash");
|
||||
setSelected(new Set());
|
||||
}}
|
||||
className={placeClass(view === "trash")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t("fx.trash")}
|
||||
<div className="border-t border-border p-2" data-testid="fx-trashes">
|
||||
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{t("fx.trash")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -774,8 +865,8 @@ export function FileBrowser({
|
||||
</HeadButton>
|
||||
<HeadButton
|
||||
label={t("fx.up")}
|
||||
disabled={view !== "files" || listing?.parent == null}
|
||||
onClick={goUp}
|
||||
disabled={view === "trash" ? trashRoot || !manyTrashes : listing?.parent == null}
|
||||
onClick={view === "trash" ? () => openTrash("*") : goUp}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</HeadButton>
|
||||
@@ -805,15 +896,36 @@ export function FileBrowser({
|
||||
aria-label={t("fx.location")}
|
||||
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md bg-surface-2 px-1 py-0.5"
|
||||
>
|
||||
{view === "trash" ? (
|
||||
{view === "trash" && manyTrashes ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openTrash("*")}
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
|
||||
trashRoot ? "font-semibold" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> {t("fx.trash")}
|
||||
</button>
|
||||
{!trashRoot && (
|
||||
<span className="flex shrink-0 items-center">
|
||||
<ChevronRight className="h-3.5 w-3.5 text-fg-muted" />
|
||||
<span className="px-2 py-1 text-sm font-semibold">
|
||||
{isHomePlace(target) ? homeName(target.home) : trashLabel}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : view === "trash" ? (
|
||||
<span className="flex items-center gap-1.5 px-2 py-1 text-sm font-medium">
|
||||
<Trash2 className="h-4 w-4" /> {t("fx.trash")}
|
||||
<Trash2 className="h-4 w-4" /> {trashLabel}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go("")}
|
||||
onContextMenu={(e) => crumbCtx.open(e, "")}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => void onDrop(e, "")}
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
|
||||
@@ -833,6 +945,7 @@ export function FileBrowser({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(crumbs.slice(0, i + 1).join("/"))}
|
||||
onContextMenu={(e) => crumbCtx.open(e, crumbs.slice(0, i + 1).join("/"))}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => void onDrop(e, crumbs.slice(0, i + 1).join("/"))}
|
||||
className={`rounded px-2 py-1 text-sm hover:bg-surface-3 ${
|
||||
@@ -1169,12 +1282,26 @@ export function FileBrowser({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : trashRoot ? (
|
||||
<TrashFolders
|
||||
homes={homes}
|
||||
items={trash}
|
||||
errors={trashErrors}
|
||||
error={error}
|
||||
loading={loading}
|
||||
homeName={homeName}
|
||||
onOpen={openTrash}
|
||||
onEmpty={(home, count) => void emptyTrashOf(home, count)}
|
||||
onRefresh={() => void load()}
|
||||
/>
|
||||
) : (
|
||||
<TrashView
|
||||
items={trash}
|
||||
error={error}
|
||||
laneName={laneName}
|
||||
onRestore={(id) => void run(() => api.files.restore(target, id), t("fx.restored"))}
|
||||
onPurge={(id) => void run(() => api.files.purge(target, id))}
|
||||
onRestore={restoreItem}
|
||||
onPurge={(id) => void purge(id)}
|
||||
onRefresh={() => void load()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1350,6 +1477,78 @@ export function FileBrowser({
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{placeCtx.menu && (
|
||||
<ContextMenu
|
||||
x={placeCtx.menu.x}
|
||||
y={placeCtx.menu.y}
|
||||
onClose={placeCtx.close}
|
||||
testId="fx-place-menu"
|
||||
>
|
||||
{"lane" in placeCtx.menu.item
|
||||
? (() => {
|
||||
const l = placeCtx.menu.item.lane;
|
||||
return (
|
||||
<>
|
||||
<MenuItem onSelect={pick(placeCtx, () => go("", l.id))}>
|
||||
{t("fx.open")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(placeCtx, () => void copyText(l.cwd))}>
|
||||
{t("fx.copyPath")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
: (() => {
|
||||
const home = (placeCtx.menu.item as { home: string }).home;
|
||||
return (
|
||||
<>
|
||||
<MenuItem onSelect={pick(placeCtx, () => go("", { home }))}>
|
||||
{t("fx.open")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(placeCtx, () => openTrash(home))}>
|
||||
{t("fx.openTrash")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{crumbCtx.menu && (
|
||||
<ContextMenu
|
||||
x={crumbCtx.menu.x}
|
||||
y={crumbCtx.menu.y}
|
||||
onClose={crumbCtx.close}
|
||||
testId="fx-crumb-menu"
|
||||
>
|
||||
{(() => {
|
||||
const dir = crumbCtx.menu.item;
|
||||
const name = dir.split("/").pop() || placeName(target);
|
||||
return (
|
||||
<>
|
||||
<MenuItem onSelect={pick(crumbCtx, () => go(dir))}>{t("fx.open")}</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={pick(crumbCtx, () =>
|
||||
download([{ name, path: dir, type: "dir", size: null, mtime: 0 }])
|
||||
)}
|
||||
>
|
||||
{t("fx.downloadZip")}
|
||||
</MenuItem>
|
||||
{!ro && clip && (
|
||||
<MenuItem onSelect={pick(crumbCtx, () => void paste(dir))}>
|
||||
{t("fx.pasteInto")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={pick(crumbCtx, () => void copyText(dir || "/"))}>
|
||||
{t("fx.copyPath")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{ask && (
|
||||
<div
|
||||
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50"
|
||||
@@ -1651,20 +1850,157 @@ function InlineName({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An admin's Trash root: one folder per user's Trash (theirs first), each
|
||||
* with its item count — or why it could not be read. Opening one shows that
|
||||
* user's Trash, where its items are restored or deleted forever.
|
||||
*/
|
||||
function TrashFolders({
|
||||
homes,
|
||||
items,
|
||||
errors,
|
||||
error,
|
||||
loading,
|
||||
homeName,
|
||||
onOpen,
|
||||
onEmpty,
|
||||
onRefresh,
|
||||
}: {
|
||||
homes: FsHome[];
|
||||
/** Every Trash's items, merged, each tagged with its `home`. */
|
||||
items: FsTrashItem[];
|
||||
/** Per home id: why its Trash could not be read. */
|
||||
errors: Record<string, string>;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
homeName: (id: string) => string;
|
||||
onOpen: (home: string) => void;
|
||||
onEmpty: (home: string, count: number) => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const menu = useContextMenu<string | null>();
|
||||
const counts = new Map<string, number>();
|
||||
for (const i of items) if (i.home) counts.set(i.home, (counts.get(i.home) ?? 0) + 1);
|
||||
const ordered = [...homes.filter((h) => h.id === "me"), ...homes.filter((h) => h.id !== "me")];
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
data-testid="fx-trash-root"
|
||||
onContextMenu={(e) => menu.open(e, null)}
|
||||
>
|
||||
<div className="border-b border-border px-4 py-2 text-sm text-fg-secondary">
|
||||
{t("fx.trashFoldersHint")}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="m-4 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-sm text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid min-h-0 flex-1 auto-rows-min grid-cols-[repeat(auto-fill,minmax(8.5rem,1fr))] gap-2 overflow-y-auto p-4">
|
||||
{ordered.map((h) => {
|
||||
const n = counts.get(h.id) ?? 0;
|
||||
const failed = errors[h.id];
|
||||
const name = h.id === "me" ? t("fx.myTrash") : homeName(h.id);
|
||||
return (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
title={t("fx.trashOf", { name: h.id === "me" ? t("fx.myHome") : h.label })}
|
||||
onClick={() => onOpen(h.id)}
|
||||
onContextMenu={(e) => menu.open(e, h.id)}
|
||||
className="flex flex-col items-center gap-1 rounded-lg px-2 py-3 text-center hover:bg-surface-2"
|
||||
data-testid="fx-trash-folder"
|
||||
>
|
||||
<span className="relative">
|
||||
<Folder
|
||||
className={`h-14 w-14 ${
|
||||
n ? "fill-accent/25 text-accent" : "fill-surface-3 text-fg-muted"
|
||||
}`}
|
||||
/>
|
||||
{h.id === "me" ? (
|
||||
<House className="absolute bottom-2 right-0 h-4 w-4 text-fg-secondary" />
|
||||
) : (
|
||||
<UserRound className="absolute bottom-2 right-0 h-4 w-4 text-fg-secondary" />
|
||||
)}
|
||||
</span>
|
||||
<span className="w-full truncate text-[13px] font-medium">{name}</span>
|
||||
<span
|
||||
className={`w-full truncate text-[11px] ${
|
||||
failed ? "text-status-danger" : "text-fg-muted"
|
||||
}`}
|
||||
title={failed}
|
||||
>
|
||||
{failed
|
||||
? failed
|
||||
: loading
|
||||
? "…"
|
||||
: n
|
||||
? t("fx.itemsCount", { count: n })
|
||||
: t("fx.trashFolderEmpty")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{menu.menu && (
|
||||
<ContextMenu
|
||||
x={menu.menu.x}
|
||||
y={menu.menu.y}
|
||||
onClose={menu.close}
|
||||
testId="fx-trash-root-menu"
|
||||
>
|
||||
{menu.menu.item !== null ? (
|
||||
(() => {
|
||||
const home = menu.menu.item;
|
||||
const n = counts.get(home) ?? 0;
|
||||
return (
|
||||
<>
|
||||
<MenuItem onSelect={pick(menu, () => onOpen(home))}>{t("fx.open")}</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger disabled={!n} onSelect={pick(menu, () => onEmpty(home, n))}>
|
||||
{t("fx.emptyTrash")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashView({
|
||||
items,
|
||||
error,
|
||||
laneName,
|
||||
onRestore,
|
||||
onPurge,
|
||||
onRefresh,
|
||||
}: {
|
||||
items: FsTrashItem[];
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
laneName: (id: number) => string;
|
||||
onRestore: (id: string) => void;
|
||||
onPurge: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const menu = useContextMenu<FsTrashItem | null>();
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="fx-trash">
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
data-testid="fx-trash"
|
||||
onContextMenu={(e) => menu.open(e, null)}
|
||||
>
|
||||
{error && (
|
||||
<div className="m-4 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-sm text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2 text-sm text-fg-secondary">
|
||||
<span className="flex-1">{t("fx.trashHint")}</span>
|
||||
<button
|
||||
@@ -1692,6 +2028,7 @@ function TrashView({
|
||||
key={i.id}
|
||||
className="border-b border-border/40 hover:bg-surface-2"
|
||||
data-testid="fx-trash-row"
|
||||
onContextMenu={(e) => menu.open(e, i)}
|
||||
>
|
||||
<td className="truncate px-4 py-1.5">
|
||||
<span className="flex items-center gap-2" title={i.path}>
|
||||
@@ -1732,13 +2069,44 @@ function TrashView({
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!items.length && (
|
||||
{!items.length && !error && (
|
||||
<div className="flex flex-col items-center gap-2 py-16 text-fg-muted">
|
||||
<Trash2 className="h-14 w-14 opacity-40" />
|
||||
<p className="text-sm">{t("fx.trashEmpty")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{menu.menu && (
|
||||
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="fx-trash-menu">
|
||||
{menu.menu.item ? (
|
||||
(() => {
|
||||
const i = menu.menu.item;
|
||||
return (
|
||||
<>
|
||||
<MenuItem onSelect={pick(menu, () => onRestore(i.id))}>
|
||||
{t("fx.restore")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(menu, () => void copyText(i.path))}>
|
||||
{t("fx.copyPath")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pick(menu, () => onPurge(i.id))}>
|
||||
{t("fx.purge")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<>
|
||||
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger disabled={!items.length} onSelect={pick(menu, () => onPurge("all"))}>
|
||||
{t("fx.emptyTrash")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
* opens sub-folders, makes a folder inline, deletes into the Trash without a
|
||||
* confirm, copies in one workspace and pastes into another (`fromLane`),
|
||||
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
|
||||
* batch over 500 MB before sending anything, and restores from the Trash.
|
||||
* batch over 500 MB before sending anything, restores from the user's own
|
||||
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
|
||||
* one saying why), opens each user's Trash from its folder (asking before a
|
||||
* purge there, sending the item's store) and
|
||||
* shows a Trash that could not be read as an error, never as empty.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -12,6 +16,9 @@ import userEvent from "@testing-library/user-event";
|
||||
import { FileExplorer } from "../FileExplorer";
|
||||
import { api, type FsListing } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
@@ -32,6 +39,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
copy: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
trash: vi.fn(),
|
||||
allTrash: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
purge: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
@@ -70,6 +78,7 @@ beforeEach(() => {
|
||||
} as never);
|
||||
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => (p === "src" ? src : root));
|
||||
vi.mocked(api.files.trash).mockResolvedValue({ items: [] });
|
||||
vi.mocked(api.files.allTrash).mockResolvedValue({ items: [], errors: [] });
|
||||
vi.mocked(api.files.homes).mockResolvedValue({
|
||||
homes: [
|
||||
{ id: "me", label: "An", readOnly: false },
|
||||
@@ -212,10 +221,178 @@ describe("FileExplorer", () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(screen.getByRole("button", { name: "Trash" }));
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
|
||||
);
|
||||
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
|
||||
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
|
||||
const trashRow = await screen.findByTestId("fx-trash-row");
|
||||
await user.click(within(trashRow).getByRole("button", { name: /Restore/ }));
|
||||
await waitFor(() => expect(api.files.restore).toHaveBeenCalledWith(1, "abc12345"));
|
||||
await waitFor(() =>
|
||||
expect(api.files.restore).toHaveBeenCalledWith({ home: "me" }, "abc12345", undefined)
|
||||
);
|
||||
});
|
||||
|
||||
it("opens an admin's Trash on one folder per user, each opening that user's Trash", async () => {
|
||||
vi.mocked(api.files.allTrash).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "bob12345",
|
||||
name: "old",
|
||||
laneId: null,
|
||||
path: "old",
|
||||
isDir: true,
|
||||
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||
store: "host",
|
||||
home: "bob",
|
||||
owner: "Bob",
|
||||
},
|
||||
],
|
||||
errors: [{ home: "me", owner: "An", message: "EMEMBERGONE" }],
|
||||
});
|
||||
vi.mocked(api.files.trash).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "bob12345",
|
||||
name: "old",
|
||||
laneId: null,
|
||||
path: "old",
|
||||
isDir: true,
|
||||
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||
store: "host",
|
||||
},
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const trashes = screen.getByTestId("fx-trashes");
|
||||
expect(within(trashes).getAllByRole("button")).toHaveLength(1);
|
||||
await user.click(within(trashes).getByRole("button", { name: "Trash" }));
|
||||
const folders = await screen.findAllByTestId("fx-trash-folder");
|
||||
expect(folders.map((f) => f.textContent)).toEqual(["My TrashEMEMBERGONE", "Bob1 item(s)"]);
|
||||
expect(screen.queryByTestId("fx-trash-row")).toBeNull();
|
||||
await user.click(folders[1]!);
|
||||
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "bob" }));
|
||||
expect(screen.getByRole("navigation", { name: "Location" }).textContent).toContain("Bob");
|
||||
const trashRow = await screen.findByTestId("fx-trash-row");
|
||||
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
|
||||
await waitFor(() =>
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("Bob's Trash") })
|
||||
)
|
||||
);
|
||||
expect(api.files.purge).not.toHaveBeenCalled();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
|
||||
);
|
||||
// Back to the folders through the path bar.
|
||||
await user.click(
|
||||
within(screen.getByRole("navigation", { name: "Location" })).getByRole("button", {
|
||||
name: "Trash",
|
||||
})
|
||||
);
|
||||
expect(await screen.findAllByTestId("fx-trash-folder")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("right-clicks a Trash folder to empty it, and a Trash row to restore it", async () => {
|
||||
vi.mocked(api.files.allTrash).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "bob12345",
|
||||
name: "old",
|
||||
laneId: null,
|
||||
path: "old",
|
||||
isDir: true,
|
||||
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||
store: "host",
|
||||
home: "bob",
|
||||
owner: "Bob",
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
vi.mocked(api.files.trash).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "bob12345",
|
||||
name: "old",
|
||||
laneId: null,
|
||||
path: "old",
|
||||
isDir: true,
|
||||
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||
store: "host",
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.files.restore).mockResolvedValue({ path: "old", laneId: null });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
|
||||
);
|
||||
const folders = await screen.findAllByTestId("fx-trash-folder");
|
||||
// My Trash is empty: its Empty Trash is disabled.
|
||||
fireEvent.contextMenu(folders[0]!);
|
||||
expect(
|
||||
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
|
||||
name: "Empty Trash",
|
||||
})
|
||||
).toHaveProperty("disabled", true);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
fireEvent.contextMenu(folders[1]!);
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
|
||||
name: "Empty Trash",
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "all"));
|
||||
expect(screen.queryByTestId("fx-trash-root-menu")).toBeNull();
|
||||
|
||||
fireEvent.contextMenu((await screen.findAllByTestId("fx-trash-folder"))[1]!);
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", { name: "Open" })
|
||||
);
|
||||
fireEvent.contextMenu(await screen.findByTestId("fx-trash-row"));
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trash-menu")).getByRole("menuitem", { name: "Restore" })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.files.restore).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
|
||||
);
|
||||
});
|
||||
|
||||
it("right-clicks a sidebar workspace and a path crumb", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
fireEvent.contextMenu(screen.getByRole("button", { name: "two" }));
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open" })
|
||||
);
|
||||
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
|
||||
fireEvent.contextMenu(screen.getByRole("button", { name: "Home" }));
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open Trash" })
|
||||
);
|
||||
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
|
||||
});
|
||||
|
||||
it("shows why a Trash could not be read instead of an empty one", async () => {
|
||||
vi.mocked(api.files.trash).mockRejectedValue(new Error("ENOENT: no such folder"));
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
|
||||
);
|
||||
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
|
||||
expect(await screen.findByText(/ENOENT: no such folder/)).toBeTruthy();
|
||||
expect(screen.queryByText("The Trash is empty.")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the user's home from the sidebar, and pastes from it into a workspace", async () => {
|
||||
@@ -242,7 +419,7 @@ describe("FileExplorer", () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(await screen.findByRole("button", { name: /Bob/ }));
|
||||
await user.click(await screen.findByRole("button", { name: "Bob" }));
|
||||
expect(await screen.findByTestId("fx-readonly")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "New" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Upload" })).toBeNull();
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
* @file GitIdentitySettings.tsx
|
||||
* @description Settings → Git → **Your git identity** (`/api/me/git`): the
|
||||
* commit author (name + email in the account's global git config — prefilled
|
||||
* from Settings → Account while unset, since a member's commits fail without
|
||||
* one), the SSH
|
||||
* from Settings → Account while unset, the email else `<username>@smartgift.vn`,
|
||||
* since a member's commits fail without one; a new SSH key is labelled with
|
||||
* that email), the SSH
|
||||
* keys a clone (Add workspace → Clone) and the pushes from that clone use —
|
||||
* an ED25519 and/or an RSA one (AWS CodeCommit takes only RSA) — make one, copy its public half to add to GitHub/GitLab, replace or delete
|
||||
* it, and whether git typed in the account's own terminal uses it too (a
|
||||
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82), the SSH
|
||||
* login name per host (AWS CodeCommit's SSH key ID, a `url.<…>.insteadOf` in
|
||||
* that same file, so a plain `ssh://git-codecommit…` URL works) — and, for
|
||||
* that same file, so a plain `ssh://git-codecommit…` URL works), and for
|
||||
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
|
||||
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
|
||||
* each tied to the one key it uses — and, for
|
||||
* a user with their own Linux account (S3), their own HTTPS
|
||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||
* table above it, so this lists them only for a member. The private key and
|
||||
@@ -205,9 +209,36 @@ export function GitIdentitySettings() {
|
||||
|
||||
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
|
||||
// An account may hold one of each; an older server answers `ssh` only.
|
||||
const keys = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
|
||||
const keys: Key[] = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
|
||||
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
|
||||
const keyOf = (type: "ed25519" | "rsa") => keys.find((k) => (k.type ?? "ed25519") === type);
|
||||
// The default key of a type (a named key sits beside it).
|
||||
const keyOf = (type: "ed25519" | "rsa") =>
|
||||
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
|
||||
const named = keys.filter((k) => k.name);
|
||||
const keyLabel = (keyId: string | null | undefined) => {
|
||||
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
||||
if (!k) return t("gitIdentity.anyKey");
|
||||
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
||||
};
|
||||
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
|
||||
const makeNamed = async () => {
|
||||
if (!newKey) return;
|
||||
const ok = await run(
|
||||
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
|
||||
t("gitIdentity.keyMade")
|
||||
);
|
||||
if (ok) setNewKey(null);
|
||||
};
|
||||
const removeNamed = async (keyId: string) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.removeKeyConfirm"),
|
||||
confirmLabel: t("gitIdentity.remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.removeKeyById(keyId));
|
||||
};
|
||||
const generate = async (type: "ed25519" | "rsa") => {
|
||||
const taken = !!keyOf(type);
|
||||
if (
|
||||
@@ -259,7 +290,7 @@ export function GitIdentitySettings() {
|
||||
return m ? [{ ...r, region: m[1] ?? "" }] : [];
|
||||
});
|
||||
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
|
||||
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1" });
|
||||
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
|
||||
const saveAws = async () => {
|
||||
let user = aws.keyId.trim();
|
||||
let host = `git-codecommit.${aws.region}.amazonaws.com`;
|
||||
@@ -269,23 +300,55 @@ export function GitIdentitySettings() {
|
||||
user = m[1];
|
||||
host = m[2].toLowerCase();
|
||||
}
|
||||
const alias = aws.alias.trim().toLowerCase();
|
||||
const ok = await run(
|
||||
() => api.gitIdentity.setSshUser({ host, user }),
|
||||
t("gitIdentity.awsSaved", { region: CODECOMMIT_RE.exec(host)?.[1] ?? host })
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host,
|
||||
user,
|
||||
...(alias ? { alias } : {}),
|
||||
...(aws.key ? { key: aws.key } : {}),
|
||||
}),
|
||||
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
||||
);
|
||||
if (ok) setAws({ ...aws, keyId: "" });
|
||||
if (ok) setAws({ ...aws, keyId: "", alias: "" });
|
||||
};
|
||||
const [other, setOther] = useState({ host: "", user: "" });
|
||||
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
|
||||
const saveOther = async () => {
|
||||
let host = other.host.trim();
|
||||
const m = /^ssh:\/\/(?:[^@/]+@)?([^/]+)/i.exec(host);
|
||||
if (m?.[1]) host = m[1];
|
||||
const alias = other.alias.trim().toLowerCase();
|
||||
const ok = await run(
|
||||
() => api.gitIdentity.setSshUser({ host, user: other.user.trim() }),
|
||||
t("gitIdentity.otherSaved", { host })
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host,
|
||||
user: other.user.trim(),
|
||||
...(alias ? { alias } : {}),
|
||||
...(other.key ? { key: other.key } : {}),
|
||||
}),
|
||||
t("gitIdentity.otherSaved", { host: alias || host })
|
||||
);
|
||||
if (ok) setOther({ host: "", user: "" });
|
||||
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
|
||||
};
|
||||
/** The key picker of a login form: every key, or one. */
|
||||
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
|
||||
<select
|
||||
aria-label={t("gitIdentity.loginKey")}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="">{t("gitIdentity.anyKey")}</option>
|
||||
{keys
|
||||
.filter((k) => k.id && (!only || k.type === only))
|
||||
.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{keyLabel(k.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
const removeLogin = async (host: string) => {
|
||||
if (
|
||||
@@ -308,7 +371,7 @@ export function GitIdentitySettings() {
|
||||
const member = id.account === "member";
|
||||
const authorShown = author ?? {
|
||||
name: id.author.name ?? me?.displayName ?? me?.username ?? "",
|
||||
email: id.author.email ?? me?.email ?? "",
|
||||
email: id.author.email ?? me?.email ?? id.defaultEmail ?? "",
|
||||
};
|
||||
const authorUnset = !id.author.name || !id.author.email;
|
||||
const authorDirty =
|
||||
@@ -422,17 +485,20 @@ export function GitIdentitySettings() {
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{awsRows.map((r) => (
|
||||
<div key={r.host} className="mt-1 flex items-center gap-2">
|
||||
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{r.region} · {r.user}
|
||||
{r.alias ? `${r.alias} · ` : ""}
|
||||
{r.region} · {r.user} · {keyLabel(r.key)}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.host}`}
|
||||
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.host))}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -441,7 +507,7 @@ export function GitIdentitySettings() {
|
||||
))}
|
||||
{id.writable && (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveAws();
|
||||
@@ -467,11 +533,22 @@ export function GitIdentitySettings() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={aws.alias}
|
||||
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
|
||||
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
@@ -479,12 +556,119 @@ export function GitIdentitySettings() {
|
||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.awsReady")}
|
||||
{awsRows.map((r) => (
|
||||
<Code key={r.host}>git clone ssh://{r.host}/v1/repos/<repo></Code>
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||
</Code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</KeyCard>
|
||||
|
||||
{(named.length > 0 || id.writable) && (
|
||||
<section
|
||||
aria-label={t("gitIdentity.moreKeysTitle")}
|
||||
className="space-y-2 rounded border border-border p-2.5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-fg-primary">
|
||||
{t("gitIdentity.moreKeysTitle")}
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
|
||||
</div>
|
||||
{id.writable && !newKey && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setNewKey({ name: "", type: "rsa" })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.newKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{newKey && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void makeNamed();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.keyName")}
|
||||
placeholder={t("gitIdentity.keyNamePlaceholder")}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
value={newKey.name}
|
||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.keyType")}
|
||||
value={newKey.type}
|
||||
onChange={(e) =>
|
||||
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
|
||||
}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="rsa">RSA 4096 (AWS)</option>
|
||||
<option value="ed25519">ED25519</option>
|
||||
</select>
|
||||
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
|
||||
{t("gitIdentity.generate")}
|
||||
</button>
|
||||
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{named.map((k) => (
|
||||
<div
|
||||
key={k.id}
|
||||
className="space-y-1 rounded border border-border-light p-2"
|
||||
data-testid="git-named-key"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[11px] font-medium text-fg-primary">
|
||||
{k.name}
|
||||
</span>
|
||||
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
||||
{k.type === "rsa" ? "RSA" : "ED25519"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||
{k.fingerprint}
|
||||
</span>
|
||||
<button type="button" className={BTN} onClick={() => void copyText(k.publicKey)}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
|
||||
onClick={() => void removeNamed(k.id!)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
|
||||
value={k.publicKey}
|
||||
rows={2}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{terminal && (
|
||||
<div className="flex items-start gap-2 rounded border border-border p-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -512,17 +696,20 @@ export function GitIdentitySettings() {
|
||||
<div className="mt-1.5 space-y-1.5">
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
|
||||
{otherRows.map((r) => (
|
||||
<div key={r.host} className="flex items-center gap-2">
|
||||
<div key={r.alias ?? r.host} className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{r.user}@{r.host}
|
||||
{r.alias ? `ssh://${r.alias}/ → ` : ""}
|
||||
{r.user}@{r.host} · {keyLabel(r.key)}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.host}`}
|
||||
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.host))}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -531,7 +718,7 @@ export function GitIdentitySettings() {
|
||||
))}
|
||||
{id.writable && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveOther();
|
||||
@@ -552,6 +739,16 @@ export function GitIdentitySettings() {
|
||||
onChange={(e) => setOther({ ...other, user: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={other.alias}
|
||||
onChange={(e) => setOther({ ...other, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
{keySelect(other.key, (key) => setOther({ ...other, key }))}
|
||||
<button
|
||||
type="submit"
|
||||
className={BTN}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* first, and a member (S3) adds an HTTPS login of their own while the host's
|
||||
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
|
||||
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
|
||||
* other host under "Other SSH hosts" — so the sections never mix.
|
||||
* other host under "Other SSH hosts" — so the sections never mix; a named key
|
||||
* is made beside the default ones, and a second AWS account is saved under
|
||||
* its own name with the one key it uses.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -21,6 +23,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
get: vi.fn(),
|
||||
generateKey: vi.fn(),
|
||||
removeKey: vi.fn(),
|
||||
removeKeyById: vi.fn(),
|
||||
setTerminalSsh: vi.fn(),
|
||||
setSshUser: vi.fn(),
|
||||
removeSshUser: vi.fn(),
|
||||
@@ -143,7 +146,7 @@ describe("GitIdentitySettings", () => {
|
||||
await userEvent.selectOptions(within(aws).getByLabelText("Region"), "eu-west-1");
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
|
||||
expect(await within(aws).findByText("eu-west-1 · APKA1")).toBeInTheDocument();
|
||||
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
|
||||
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
|
||||
// A CodeCommit host never shows up again under "other hosts".
|
||||
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
|
||||
@@ -166,7 +169,7 @@ describe("GitIdentitySettings", () => {
|
||||
host: "git.example.com",
|
||||
user: "deploy",
|
||||
});
|
||||
expect(await screen.findByText("deploy@git.example.com")).toBeInTheDocument();
|
||||
expect(await screen.findByText("deploy@git.example.com · Any key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns while the commit author is unset and saves it", async () => {
|
||||
@@ -194,4 +197,63 @@ describe("GitIdentitySettings", () => {
|
||||
expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
|
||||
});
|
||||
|
||||
it("makes a named key and saves a second AWS account under its name with that key", async () => {
|
||||
const RSA = {
|
||||
id: "id_rsa",
|
||||
type: "rsa" as const,
|
||||
name: null,
|
||||
publicKey: "ssh-rsa A x",
|
||||
fingerprint: "SHA256:r",
|
||||
};
|
||||
const PROD = {
|
||||
id: "id_rsa_aws-prod",
|
||||
type: "rsa" as const,
|
||||
name: "aws-prod",
|
||||
publicKey: "ssh-rsa B prod",
|
||||
fingerprint: "SHA256:p",
|
||||
};
|
||||
const H = "git-codecommit.ap-southeast-1.amazonaws.com";
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
|
||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
||||
view({ sshKeys: [RSA, PROD], sshUsers: [] })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||
view({
|
||||
sshKeys: [RSA, PROD],
|
||||
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
|
||||
})
|
||||
);
|
||||
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
||||
view({ sshKeys: [RSA], sshUsers: [] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
const more = await screen.findByRole("region", { name: "More SSH keys" });
|
||||
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
|
||||
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
|
||||
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
|
||||
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
|
||||
|
||||
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
|
||||
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
|
||||
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: H,
|
||||
user: "APKA2",
|
||||
alias: "aws-prod",
|
||||
key: "id_rsa_aws-prod",
|
||||
});
|
||||
expect(
|
||||
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
|
||||
).toBeInTheDocument();
|
||||
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
|
||||
await waitFor(() =>
|
||||
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file MemberTmuxCard.tsx
|
||||
* @description The Users page's tmux card (one Linux user per account only,
|
||||
* @description The Administration page's Server-tab tmux card (one Linux user per account only,
|
||||
* `GET /api/users/tmux`): each member's own tmux server — the version it runs
|
||||
* beside the installed one, its sessions, whether an agent of theirs is
|
||||
* working — so an admin sees who still runs an old tmux after an upgrade.
|
||||
|
||||
@@ -115,6 +115,7 @@ export const i18nReady = i18n
|
||||
"splash",
|
||||
"lanes",
|
||||
"users",
|
||||
"admin",
|
||||
"onboarding",
|
||||
"artifacts",
|
||||
],
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "Administration",
|
||||
"description": "Accounts and the server AgentHub runs on.",
|
||||
"tabs": {
|
||||
"users": "Users",
|
||||
"server": "Server"
|
||||
},
|
||||
"server": {
|
||||
"title": "Server",
|
||||
"uptime": "up {{v}}",
|
||||
"uptimeDays": "{{d}} d {{h}} h",
|
||||
"uptimeHours": "{{h}} h {{m}} min",
|
||||
"cpu": "CPU",
|
||||
"cores": "{{n}} cores · load {{load}}",
|
||||
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
|
||||
"memory": "Memory",
|
||||
"swap": "Swap",
|
||||
"homesDisk": "Homes disk",
|
||||
"homesDiskHelp": "The disk that holds the users' home folders.",
|
||||
"free": "free",
|
||||
"usageTitle": "Usage by user",
|
||||
"usageHelp": "Live, sampled over {{s}} s. A process counts for a user when it runs as their Linux account or inside one of their workspaces' sessions. Memory is resident (RSS); shared pages count once per process. Click a row for its busiest processes.",
|
||||
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
|
||||
"who": "User",
|
||||
"processes": "Processes",
|
||||
"home": "Home",
|
||||
"sampling": "Sampling…",
|
||||
"hostGroup": "Host account (AgentHub and other host processes)",
|
||||
"systemGroup": "System (root and other accounts)",
|
||||
"disks": "Disks",
|
||||
"mount": "Mount",
|
||||
"device": "Device",
|
||||
"used": "Used",
|
||||
"homesBadge": "homes",
|
||||
"gpus": "GPUs"
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
"shareDone": "Shared to the Hub",
|
||||
"unshareDone": "Removed from the Hub",
|
||||
"open": "Open",
|
||||
"copyLink": "Copy link",
|
||||
"preview": "Preview",
|
||||
"download": "Download",
|
||||
"install": "Install",
|
||||
|
||||
@@ -577,7 +577,7 @@
|
||||
"fx.cutDone": "Cut {{count}} item(s) — open a folder (or another workspace) and Paste.",
|
||||
"fx.pasted": "Pasted.",
|
||||
"fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
|
||||
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `sudo agenthub server install-system` once.",
|
||||
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `agenthub server install-system` once.",
|
||||
"fx.conflictTitle": "Already there",
|
||||
"fx.conflictBody": "This name is taken in the destination: {{names}}",
|
||||
"fx.conflictAll": "Do this for every remaining file",
|
||||
@@ -591,6 +591,17 @@
|
||||
"fx.purge": "Delete forever",
|
||||
"fx.restored": "Restored.",
|
||||
"fx.trashEmpty": "The Trash is empty.",
|
||||
"fx.trashOf": "{{name}}'s Trash",
|
||||
"fx.myTrash": "My Trash",
|
||||
"fx.trashAt": "Trash ({{name}})",
|
||||
"fx.trashFoldersHint": "Each folder is one user's Trash — open it to restore or delete forever.",
|
||||
"fx.trashFolderEmpty": "Empty",
|
||||
"fx.openTrash": "Open Trash",
|
||||
"fx.purged": "Deleted forever.",
|
||||
"fx.emptyTrashTitle": "Empty the Trash?",
|
||||
"fx.emptyTrashBody": "All {{count}} items in {{where}} will be deleted forever. This cannot be undone.",
|
||||
"fx.purgeTitle": "Delete forever?",
|
||||
"fx.purgeBody": "“{{name}}” in {{where}} will be deleted forever. This cannot be undone.",
|
||||
"changes.openExplorer": "Open File Explorer — manage, upload and download files",
|
||||
"changes.openInExplorer": "Open in File Explorer",
|
||||
"fx.places": "Workspaces",
|
||||
@@ -620,7 +631,7 @@
|
||||
"fx.noWorkspace": "There is no workspace yet. Add one first, then its files show here.",
|
||||
"fx.myHome": "Home",
|
||||
"fx.userHomes": "Users' homes",
|
||||
"fx.readOnlyHome": "Another user's home — view and download only",
|
||||
"fx.readOnlyHome": "Another user's home — view and download only; their Trash can be restored or emptied",
|
||||
"fx.readOnly": "Read-only",
|
||||
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
|
||||
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
|
||||
|
||||
@@ -87,5 +87,6 @@
|
||||
"sidebar": "Sidebar",
|
||||
"now": "now",
|
||||
"users": "Users",
|
||||
"admin": "Administration",
|
||||
"files": "Files"
|
||||
}
|
||||
|
||||
@@ -937,7 +937,21 @@
|
||||
"otherHost": "Host",
|
||||
"otherUser": "User name",
|
||||
"otherSaved": "SSH user saved for {{host}}",
|
||||
"privateNote": "Only the public key is ever shown; the private key never leaves the server."
|
||||
"privateNote": "Only the public key is ever shown; the private key never leaves the server.",
|
||||
"anyKey": "Any key",
|
||||
"defaultEd": "ED25519 (default)",
|
||||
"defaultRsa": "RSA (default)",
|
||||
"loginKey": "Key",
|
||||
"alias": "Account name",
|
||||
"aliasPlaceholder": "name (optional)",
|
||||
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
||||
"awsMany": "Several AWS accounts? Make one more RSA key below for each, upload it to that account, then save its SSH key ID here with an account name and that key. Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||
"moreKeysTitle": "More SSH keys",
|
||||
"moreKeysSub": "One per extra account — another AWS account, a second GitHub user. A login that names its key uses only that key.",
|
||||
"newKey": "New key",
|
||||
"keyName": "Key name",
|
||||
"keyNamePlaceholder": "e.g. aws-prod",
|
||||
"keyType": "Key type"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
"title": "Members' tmux",
|
||||
"description": "Each member's own tmux server keeps the version it started with until its last pane closes. Installed: tmux {{version}}.",
|
||||
"refresh": "Refresh",
|
||||
"notMatched": "Run `sudo agenthub server install-system` once: until then an older member server is asked with the installed tmux, which can leave it stuck at 100% CPU.",
|
||||
"notMatched": "Run `agenthub server install-system` once: until then an older member server is asked with the installed tmux, which can leave it stuck at 100% CPU.",
|
||||
"empty": "No member has a tmux server running.",
|
||||
"user": "User",
|
||||
"version": "tmux",
|
||||
@@ -234,5 +234,10 @@
|
||||
"errors": {
|
||||
"ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server."
|
||||
}
|
||||
},
|
||||
"rowMenu": {
|
||||
"openHome": "Open home in Files",
|
||||
"copyUsername": "Copy username",
|
||||
"copyEmail": "Copy email"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "Quản trị",
|
||||
"description": "Tài khoản và máy chủ AgentHub đang chạy.",
|
||||
"tabs": {
|
||||
"users": "Người dùng",
|
||||
"server": "Máy chủ"
|
||||
},
|
||||
"server": {
|
||||
"title": "Máy chủ",
|
||||
"uptime": "đã chạy {{v}}",
|
||||
"uptimeDays": "{{d}} ngày {{h}} giờ",
|
||||
"uptimeHours": "{{h}} giờ {{m}} phút",
|
||||
"cpu": "CPU",
|
||||
"cores": "{{n}} lõi · tải {{load}}",
|
||||
"cpuHelp": "Tỷ lệ trên toàn máy: 100% nghĩa là mọi lõi đều bận.",
|
||||
"memory": "Bộ nhớ",
|
||||
"swap": "Swap",
|
||||
"homesDisk": "Ổ chứa thư mục người dùng",
|
||||
"homesDiskHelp": "Ổ đĩa chứa thư mục home của các người dùng.",
|
||||
"free": "trống",
|
||||
"usageTitle": "Mức sử dụng theo người dùng",
|
||||
"usageHelp": "Số liệu trực tiếp, lấy mẫu trong {{s}} giây. Một tiến trình được tính cho người dùng khi nó chạy bằng tài khoản Linux của họ hoặc trong một phiên thuộc workspace của họ. Bộ nhớ là RSS; trang nhớ dùng chung được tính cho mỗi tiến trình. Bấm vào một dòng để xem các tiến trình nặng nhất.",
|
||||
"usageUnavailable": "Mức dùng theo người dùng được đọc từ /proc nên chỉ có trên Linux.",
|
||||
"who": "Người dùng",
|
||||
"processes": "Tiến trình",
|
||||
"home": "Thư mục home",
|
||||
"sampling": "Đang lấy mẫu…",
|
||||
"hostGroup": "Tài khoản máy chủ (AgentHub và tiến trình khác của host)",
|
||||
"systemGroup": "Hệ thống (root và tài khoản khác)",
|
||||
"disks": "Ổ đĩa",
|
||||
"mount": "Điểm gắn",
|
||||
"device": "Thiết bị",
|
||||
"used": "Đã dùng",
|
||||
"homesBadge": "home",
|
||||
"gpus": "GPU"
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
"shareDone": "Đã chia sẻ lên Hub",
|
||||
"unshareDone": "Đã gỡ khỏi Hub",
|
||||
"open": "Mở",
|
||||
"copyLink": "Sao chép link",
|
||||
"preview": "Xem",
|
||||
"download": "Tải về",
|
||||
"install": "Cài",
|
||||
|
||||
@@ -577,7 +577,7 @@
|
||||
"fx.cutDone": "Đã cắt {{count}} mục — mở thư mục (hoặc workspace khác) rồi Dán.",
|
||||
"fx.pasted": "Đã dán.",
|
||||
"fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
|
||||
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `sudo agenthub server install-system` một lần.",
|
||||
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `agenthub server install-system` một lần.",
|
||||
"fx.conflictTitle": "Đã có sẵn",
|
||||
"fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
|
||||
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
|
||||
@@ -591,6 +591,17 @@
|
||||
"fx.purge": "Xoá vĩnh viễn",
|
||||
"fx.restored": "Đã khôi phục.",
|
||||
"fx.trashEmpty": "Thùng rác trống.",
|
||||
"fx.trashOf": "Thùng rác của {{name}}",
|
||||
"fx.myTrash": "Thùng rác của tôi",
|
||||
"fx.trashAt": "Thùng rác ({{name}})",
|
||||
"fx.trashFoldersHint": "Mỗi thư mục là Thùng rác của một user — mở ra để khôi phục hoặc xoá vĩnh viễn.",
|
||||
"fx.trashFolderEmpty": "Trống",
|
||||
"fx.openTrash": "Mở Thùng rác",
|
||||
"fx.purged": "Đã xoá vĩnh viễn.",
|
||||
"fx.emptyTrashTitle": "Dọn sạch Thùng rác?",
|
||||
"fx.emptyTrashBody": "Toàn bộ {{count}} mục trong {{where}} sẽ bị xoá vĩnh viễn, không khôi phục được.",
|
||||
"fx.purgeTitle": "Xoá vĩnh viễn?",
|
||||
"fx.purgeBody": "“{{name}}” trong {{where}} sẽ bị xoá vĩnh viễn, không khôi phục được.",
|
||||
"changes.openExplorer": "Mở Quản lý file — quản lý, tải lên và tải về file",
|
||||
"changes.openInExplorer": "Mở trong Quản lý file",
|
||||
"fx.places": "Workspace",
|
||||
@@ -620,7 +631,7 @@
|
||||
"fx.noWorkspace": "Chưa có workspace nào. Hãy thêm một workspace, file của nó sẽ hiện ở đây.",
|
||||
"fx.myHome": "Thư mục home",
|
||||
"fx.userHomes": "Home của các user",
|
||||
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về",
|
||||
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về; Thùng rác của họ thì khôi phục hoặc xoá được",
|
||||
"fx.readOnly": "Chỉ đọc",
|
||||
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
|
||||
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
|
||||
|
||||
@@ -87,5 +87,6 @@
|
||||
"sidebar": "Thanh bên",
|
||||
"now": "bây giờ",
|
||||
"users": "Người dùng",
|
||||
"admin": "Quản trị",
|
||||
"files": "Quản lý file"
|
||||
}
|
||||
|
||||
@@ -937,7 +937,21 @@
|
||||
"otherHost": "Host",
|
||||
"otherUser": "Tên user",
|
||||
"otherSaved": "Đã lưu SSH user cho {{host}}",
|
||||
"privateNote": "Chỉ public key được hiển thị; private key không bao giờ rời khỏi server."
|
||||
"privateNote": "Chỉ public key được hiển thị; private key không bao giờ rời khỏi server.",
|
||||
"anyKey": "Mọi key",
|
||||
"defaultEd": "ED25519 (mặc định)",
|
||||
"defaultRsa": "RSA (mặc định)",
|
||||
"loginKey": "Key",
|
||||
"alias": "Tên tài khoản",
|
||||
"aliasPlaceholder": "tên (tuỳ chọn)",
|
||||
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
||||
"awsMany": "Nhiều tài khoản AWS? Tạo thêm một RSA key bên dưới cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||
"moreKeysTitle": "Thêm SSH key",
|
||||
"moreKeysSub": "Mỗi key cho một tài khoản phụ — tài khoản AWS khác, user GitHub thứ hai. Tài khoản nào gắn key thì chỉ dùng đúng key đó.",
|
||||
"newKey": "Key mới",
|
||||
"keyName": "Tên key",
|
||||
"keyNamePlaceholder": "vd aws-prod",
|
||||
"keyType": "Loại key"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
"title": "Tmux của member",
|
||||
"description": "Mỗi member có tmux server riêng, giữ phiên bản lúc nó khởi động cho đến khi pane cuối cùng đóng. Bản đã cài: tmux {{version}}.",
|
||||
"refresh": "Làm mới",
|
||||
"notMatched": "Chạy `sudo agenthub server install-system` một lần: trước đó, server cũ của member được hỏi bằng tmux bản mới và có thể kẹt ở 100% CPU.",
|
||||
"notMatched": "Chạy `agenthub server install-system` một lần: trước đó, server cũ của member được hỏi bằng tmux bản mới và có thể kẹt ở 100% CPU.",
|
||||
"empty": "Không member nào đang chạy tmux server.",
|
||||
"user": "Người dùng",
|
||||
"version": "tmux",
|
||||
@@ -234,5 +234,10 @@
|
||||
"errors": {
|
||||
"ETMUXSTUCK": "Server không dừng. Chạy lệnh `sudo kill -9` được nêu, bằng quyền root, trên máy server."
|
||||
}
|
||||
},
|
||||
"rowMenu": {
|
||||
"openHome": "Mở home trong Files",
|
||||
"copyUsername": "Sao chép username",
|
||||
"copyEmail": "Sao chép email"
|
||||
}
|
||||
}
|
||||
|
||||
+94
-15
@@ -569,6 +569,46 @@ export interface MemberTmux {
|
||||
users: MemberTmuxRow[];
|
||||
}
|
||||
|
||||
/** One account's (or the host's / other accounts') live share of the machine (`GET /api/host`). */
|
||||
export interface HostUsageGroup {
|
||||
/** `user:<id>`, `host` (the host account outside any workspace) or `system`. */
|
||||
key: string;
|
||||
kind: "user" | "host" | "system";
|
||||
userId?: number;
|
||||
username?: string;
|
||||
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
|
||||
cpu: number;
|
||||
/** Resident memory, bytes (shared pages count in every process). */
|
||||
memory: number;
|
||||
memoryPercent: number;
|
||||
processes: number;
|
||||
/** Busiest processes, by the kernel's short name only. */
|
||||
top: { pid: number; name: string; cpu: number; memory: number }[];
|
||||
}
|
||||
/** `GET /api/host` — hardware, load, disks and per-user usage. */
|
||||
export interface HostStats {
|
||||
at: string;
|
||||
hostname: string;
|
||||
os: { platform: string; release: string; distro: string | null; arch: string };
|
||||
uptime: number;
|
||||
cpu: { model: string | null; cores: number; load: number[] };
|
||||
memory: { total: number; available: number; used: number; swapTotal: number; swapUsed: number };
|
||||
disks: {
|
||||
device: string;
|
||||
mount: string;
|
||||
type: string;
|
||||
kind: "HDD" | "SSD" | null;
|
||||
total: number;
|
||||
used: number;
|
||||
free: number;
|
||||
}[];
|
||||
/** The mount holding the users' homes, or null. */
|
||||
homesMount: string | null;
|
||||
gpus: { name: string; utilization: number; memoryUsed: number; memoryTotal: number }[];
|
||||
/** null off Linux. */
|
||||
usage: { cpu: number; sampleMs: number; groups: HostUsageGroup[] } | null;
|
||||
}
|
||||
|
||||
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
|
||||
export type UserStorage = {
|
||||
bytes: number;
|
||||
@@ -842,6 +882,11 @@ export interface FsTrashItem {
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
deletedAt: string;
|
||||
/** A home's recycle bin only: which of the user's trash folders holds it. */
|
||||
store?: "home" | "host";
|
||||
/** The merged view only (`allTrash`): the home it belongs to, and its user. */
|
||||
home?: string;
|
||||
owner?: string;
|
||||
}
|
||||
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
|
||||
export type FsConflict = "fail" | "skip" | "keep" | "replace";
|
||||
@@ -913,6 +958,10 @@ export const api = {
|
||||
/** DELETE /api/me/avatar — back to the initials. */
|
||||
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
|
||||
},
|
||||
/** GET /api/host — the Server tab: hardware and each user's CPU/memory share (staff). */
|
||||
host: {
|
||||
stats: () => request<HostStats>("/host"),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
/** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */
|
||||
@@ -2638,15 +2687,22 @@ export const api = {
|
||||
body: JSON.stringify({ paths }),
|
||||
}),
|
||||
trash: (t: FsPlace) => request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
|
||||
restore: (t: FsPlace, id: string) =>
|
||||
/** Every Trash the caller may open, merged (an admin: every user's). */
|
||||
allTrash: () =>
|
||||
request<{
|
||||
items: FsTrashItem[];
|
||||
errors?: { home: string; owner: string; message: string }[];
|
||||
keepDays?: number;
|
||||
}>("/homes/trash"),
|
||||
restore: (t: FsPlace, id: string, store?: FsTrashItem["store"]) =>
|
||||
request<{ path: string; laneId: number | null }>(fsUrl(t, "/trash/restore"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
body: JSON.stringify(store ? { id, store } : { id }),
|
||||
}),
|
||||
purge: (t: FsPlace, id: string | "all") =>
|
||||
purge: (t: FsPlace, id: string | "all", store?: FsTrashItem["store"]) =>
|
||||
request<{ purged: number }>(fsUrl(t, "/trash/purge"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
body: JSON.stringify(store ? { id, store } : { id }),
|
||||
}),
|
||||
/** A zip of `paths` (files and folders), for a plain link/anchor download. */
|
||||
zipUrl: (t: FsPlace, paths: string[]) => {
|
||||
@@ -2708,22 +2764,30 @@ export const api = {
|
||||
* Make the SSH key; `replace` swaps an existing one for a new pair.
|
||||
* `type`: ed25519 (default) or rsa (RSA 4096 — AWS CodeCommit takes only RSA).
|
||||
*/
|
||||
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519") =>
|
||||
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519", name?: string) =>
|
||||
request<GitIdentity>("/me/git/ssh-key", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace, type }),
|
||||
body: JSON.stringify(name ? { replace, type, name } : { replace, type }),
|
||||
}),
|
||||
/** DELETE /api/me/git/ssh-key — the key of `type`, or every key. */
|
||||
/** DELETE /api/me/git/ssh-key — the default key of `type`, or every key. */
|
||||
removeKey: (type?: "ed25519" | "rsa") =>
|
||||
request<GitIdentity>(`/me/git/ssh-key${type ? `?type=${type}` : ""}`, { method: "DELETE" }),
|
||||
/** DELETE /api/me/git/ssh-key?id= — one key by its id (a named one). */
|
||||
removeKeyById: (id: string) =>
|
||||
request<GitIdentity>(`/me/git/ssh-key?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
|
||||
setTerminalSsh: (on: boolean) =>
|
||||
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
|
||||
/** PUT /api/me/git/ssh-user — the SSH login name for a host (CodeCommit: the SSH key ID). */
|
||||
setSshUser: (body: { host: string; user: string }) =>
|
||||
/**
|
||||
* PUT /api/me/git/ssh-user — an SSH login (CodeCommit: the SSH key ID as
|
||||
* the user): for the host itself, or with `alias` one more account on it
|
||||
* (`ssh://<alias>/…`); `key` = the only key it uses.
|
||||
*/
|
||||
setSshUser: (body: { host: string; user: string; alias?: string; key?: string }) =>
|
||||
request<GitIdentity>("/me/git/ssh-user", { method: "PUT", body: JSON.stringify(body) }),
|
||||
removeSshUser: (host: string) =>
|
||||
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(host)}`, { method: "DELETE" }),
|
||||
/** DELETE a login by its host (the host's own rule) or its alias. */
|
||||
removeSshUser: (from: string) =>
|
||||
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(from)}`, { method: "DELETE" }),
|
||||
saveHttps: (body: { host: string; username: string; password: string }) =>
|
||||
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
|
||||
removeHttps: (host: string) =>
|
||||
@@ -4254,14 +4318,29 @@ export interface GitIdentity {
|
||||
/** Whether a plain `git` in the account's terminal uses the key (`~/.gitconfig` core.sshCommand); `foreign` = the user set their own. */
|
||||
terminal?: "on" | "off" | "foreign";
|
||||
} | null;
|
||||
/** Every key the account holds (ED25519 first); absent from an older server. */
|
||||
sshKeys?: { type?: "ed25519" | "rsa"; publicKey: string; fingerprint: string }[];
|
||||
/**
|
||||
* Every key the account holds: the default ED25519 and RSA first, then the
|
||||
* named ones (`name`; `id` = its file name); absent from an older server.
|
||||
*/
|
||||
sshKeys?: {
|
||||
id?: string;
|
||||
type?: "ed25519" | "rsa";
|
||||
name?: string | null;
|
||||
publicKey: string;
|
||||
fingerprint: string;
|
||||
}[];
|
||||
/** The terminal switch (shared by both keys); null without a key. */
|
||||
sshTerminal?: "on" | "off" | "foreign" | null;
|
||||
/** SSH login name per host (`url.<…>.insteadOf` in `~/.gitconfig`); absent from an older server. */
|
||||
sshUsers?: { host: string; user: string }[];
|
||||
/**
|
||||
* SSH logins (`url.<…>.insteadOf` in `~/.gitconfig`): `alias` null = the
|
||||
* host's own (`ssh://<host>/`), else `ssh://<alias>/`; `key` = the one key
|
||||
* it uses, null = every key. Absent from an older server.
|
||||
*/
|
||||
sshUsers?: { host: string; user: string; alias?: string | null; key?: string | null }[];
|
||||
/** The account's commit author (`git config --global`); null fields = unset. */
|
||||
author: { name: string | null; email: string | null };
|
||||
/** The suggested email while none is set: `<username>@smartgift.vn`. */
|
||||
defaultEmail?: string | null;
|
||||
https: {
|
||||
protocol: "http" | "https";
|
||||
host: string;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @file Administration.tsx
|
||||
* @description The Administration page (`/admin`, a sidebar item; `/users`
|
||||
* redirects here), server mode only and only for an admin or analyst: a tab
|
||||
* shell like `History.tsx`, the active tab in `?tab=` — **Users** (the
|
||||
* accounts, the account pool and the audit log, `Users.tsx`) and **Server**
|
||||
* (the machine's hardware and each user's share of it, `ServerPanel`).
|
||||
* Local mode and a developer are sent home, like the Users page always did.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Server, ShieldCheck, Users as UsersIcon } from "lucide-react";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { ServerPanel } from "../components/admin/ServerPanel";
|
||||
import { Users } from "./Users";
|
||||
|
||||
interface AdminTab {
|
||||
id: string;
|
||||
label: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
export function Administration() {
|
||||
const { t } = useTranslation("admin");
|
||||
const auth = useAuth();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const me = auth.state.mode === "server" ? auth.state.user : null;
|
||||
|
||||
if (!me || me.role === "developer") return <Navigate to="/" replace />;
|
||||
|
||||
const tabs: AdminTab[] = [
|
||||
{ id: "users", label: t("tabs.users"), Icon: UsersIcon },
|
||||
{ id: "server", label: t("tabs.server"), Icon: Server },
|
||||
];
|
||||
const first = tabs[0]!;
|
||||
const active = tabs.find((x) => x.id === params.get("tab")) ?? first;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<ShieldCheck className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageTabs
|
||||
ariaLabel={t("title")}
|
||||
activeId={active.id}
|
||||
onSelect={(id) => setParams(id === first.id ? {} : { tab: id }, { replace: true })}
|
||||
tabs={tabs}
|
||||
>
|
||||
{active.id === "server" ? (
|
||||
<ServerPanel isAdmin={me.role === "admin"} />
|
||||
) : (
|
||||
<Users embedded />
|
||||
)}
|
||||
</PageTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,8 @@
|
||||
* admin's switch in Settings → AI tools (`ArtifactsHubSettings`) turns Hub
|
||||
* installs off for everyone. Previews of a file are
|
||||
* the server's sandboxed `raw` route in a sandboxed iframe, so nothing a
|
||||
* shared HTML file carries runs on the dashboard's origin.
|
||||
* shared HTML file carries runs on the dashboard's origin. Right-clicking a
|
||||
* card offers the same actions as its buttons, plus copying a page's link.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -43,6 +44,8 @@ import { eventBus } from "../lib/eventBus";
|
||||
import { toast } from "../lib/toast";
|
||||
import { confirmDialog } from "../lib/confirm";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||
import {
|
||||
FileUploadPanel,
|
||||
PluginAddPanel,
|
||||
@@ -325,12 +328,20 @@ function ArtifactCard({
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const Icon = KIND_ICON[a.kind];
|
||||
const menu = useContextMenu<null>();
|
||||
const pick = (fn: () => void) => () => {
|
||||
menu.close();
|
||||
fn();
|
||||
};
|
||||
const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? "");
|
||||
const canPreview = (a.kind === "file" || a.kind === "skill") && a.files.length > 0;
|
||||
const btn =
|
||||
"flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||
return (
|
||||
<li
|
||||
className="flex flex-col gap-2 rounded-lg border border-border bg-surface-1 p-3"
|
||||
data-testid={`artifact-${a.id}`}
|
||||
onContextMenu={(e) => menu.open(e, null)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" />
|
||||
@@ -363,12 +374,8 @@ function ArtifactCard({
|
||||
{t("open")}
|
||||
</a>
|
||||
)}
|
||||
{(a.kind === "file" || a.kind === "skill") && a.files.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
onClick={() => onPreview(a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? ""))}
|
||||
>
|
||||
{canPreview && (
|
||||
<button type="button" className={btn} onClick={() => onPreview(previewPath)}>
|
||||
<Eye className="h-3 w-3" />
|
||||
{t("preview")}
|
||||
</button>
|
||||
@@ -410,6 +417,47 @@ function ArtifactCard({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{menu.menu && (
|
||||
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="artifact-menu">
|
||||
{a.kind === "page" && a.url && (
|
||||
<>
|
||||
<MenuItem onSelect={pick(() => window.open(a.url!, "_blank", "noopener,noreferrer"))}>
|
||||
{t("open")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(() => void copyText(a.url!))}>{t("copyLink")}</MenuItem>
|
||||
</>
|
||||
)}
|
||||
{canPreview && (
|
||||
<MenuItem onSelect={pick(() => onPreview(previewPath))}>{t("preview")}</MenuItem>
|
||||
)}
|
||||
{a.kind === "file" && a.files.length > 0 && (
|
||||
<MenuItem
|
||||
onSelect={pick(() => {
|
||||
window.location.href = api.artifacts.rawUrl(a.id, a.files[0]?.path, true);
|
||||
})}
|
||||
>
|
||||
{t("download")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{(a.kind === "skill" || a.kind === "plugin") && (
|
||||
<MenuItem disabled={!canInstall} onSelect={pick(onInstall)}>
|
||||
{t("install")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{a.canChange && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{server && (
|
||||
<MenuItem onSelect={pick(onShare)}>{t(a.shared ? "unshare" : "share")}</MenuItem>
|
||||
)}
|
||||
<MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem>
|
||||
<MenuItem danger onSelect={pick(onDelete)}>
|
||||
{t("delete")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
+64
-13
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file Users.tsx
|
||||
* @description The Users page (`/users`, a sidebar item), server mode only and
|
||||
* @description The Users tab of the Administration page (`/admin`, which
|
||||
* `/users` redirects to — `Administration.tsx`), server mode only and
|
||||
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
||||
* role, state, workspaces, sessions, the tokens/$ it used in the last 7 and
|
||||
* 30 days (`GET /api/users`) and how much disk its home folder takes
|
||||
@@ -9,18 +10,19 @@
|
||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
|
||||
* as one change), and the Claude/Codex account pool every user's sessions run
|
||||
* on (`AccountPoolCard`), and (one Linux user per account) each member's own
|
||||
* tmux server beside the installed version, restartable by an admin
|
||||
* (`MemberTmuxCard`). Every change asks for the admin's OWN password again (the
|
||||
* on (`AccountPoolCard`). (Each member's own tmux server — one Linux user per
|
||||
* account — moved to the Server tab, `MemberTmuxCard` in `ServerPanel`.)
|
||||
* Every change asks for the admin's OWN password again (the
|
||||
* server refuses without it); an analyst only reads. A row shows the display
|
||||
* name and email under the username when set.
|
||||
* name and email under the username when set; right-clicking a row offers
|
||||
* Edit, Open home (in /files), and copying the username or email.
|
||||
* Laid out like the other list pages: icon header, a row of totals
|
||||
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -50,13 +52,14 @@ import { ConfirmModal } from "../components/ConfirmModal";
|
||||
import { StatCard } from "../components/StatCard";
|
||||
import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
|
||||
import { AccountPoolCard } from "../components/users/AccountPoolCard";
|
||||
import { MemberTmuxCard } from "../components/users/MemberTmuxCard";
|
||||
import { TableRowSkeleton } from "../components/Skeleton";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
|
||||
export function Users() {
|
||||
export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const { t } = useTranslation("users");
|
||||
const auth = useAuth();
|
||||
const me = auth.state.mode === "server" ? auth.state.user : null;
|
||||
@@ -67,6 +70,8 @@ export function Users() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editing, setEditing] = useState<ServerUserUsage | null>(null);
|
||||
const rowMenu = useContextMenu<ServerUserUsage>();
|
||||
const navigate = useNavigate();
|
||||
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
||||
const [measuring, setMeasuring] = useState(false);
|
||||
|
||||
@@ -127,11 +132,16 @@ export function Users() {
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<UsersIcon className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
{!embedded && (
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<UsersIcon className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
{/* Under Administration's tab bar the tab already names the page. */}
|
||||
<h1 className={embedded ? "sr-only" : "text-lg font-semibold text-fg-primary"}>
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,6 +241,7 @@ export function Users() {
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
onContextMenu={(e) => rowMenu.open(e, u)}
|
||||
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
<td className="px-5 py-3">
|
||||
@@ -298,8 +309,48 @@ export function Users() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{rowMenu.menu &&
|
||||
(() => {
|
||||
const u = rowMenu.menu.item;
|
||||
const pick = (fn: () => void) => () => {
|
||||
rowMenu.close();
|
||||
fn();
|
||||
};
|
||||
return (
|
||||
<ContextMenu
|
||||
x={rowMenu.menu.x}
|
||||
y={rowMenu.menu.y}
|
||||
onClose={rowMenu.close}
|
||||
testId="users-row-menu"
|
||||
>
|
||||
{isAdmin && (
|
||||
<MenuItem onSelect={pick(() => setEditing(u))}>{t("edit.button")}</MenuItem>
|
||||
)}
|
||||
{isAdmin && !u.disabled && (
|
||||
<MenuItem
|
||||
onSelect={pick(() =>
|
||||
navigate(
|
||||
`/files?home=${encodeURIComponent(u.id === me.id ? "me" : u.username)}`
|
||||
)
|
||||
)}
|
||||
>
|
||||
{t("rowMenu.openHome")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{isAdmin && <MenuSeparator />}
|
||||
<MenuItem onSelect={pick(() => void copyText(u.username))}>
|
||||
{t("rowMenu.copyUsername")}
|
||||
</MenuItem>
|
||||
{u.email && (
|
||||
<MenuItem onSelect={pick(() => void copyText(u.email!))}>
|
||||
{t("rowMenu.copyEmail")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
})()}
|
||||
|
||||
<AccountPoolCard isAdmin={isAdmin} />
|
||||
<MemberTmuxCard isAdmin={isAdmin} />
|
||||
|
||||
<section className="card">
|
||||
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @file Administration.test.tsx
|
||||
* @description The Administration page (`/admin`): local mode and a developer
|
||||
* are sent home; staff land on the Users tab; the Server tab shows the
|
||||
* machine's hardware, every disk, and each user's CPU/memory/home share from
|
||||
* `GET /api/host`, with a user's busiest processes on click.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import type { AuthState, HostStats } from "../../lib/api";
|
||||
|
||||
let authState: AuthState = { mode: "local" };
|
||||
vi.mock("../../components/AuthGate", () => ({
|
||||
useAuth: () => ({ state: authState, logout: async () => {} }),
|
||||
}));
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
const stats: HostStats = {
|
||||
at: "2026-10-15T00:00:00Z",
|
||||
hostname: "box",
|
||||
os: { platform: "linux", release: "7.0.0", distro: "Ubuntu 26.04", arch: "x64" },
|
||||
uptime: 90000,
|
||||
cpu: { model: "i5-7500", cores: 4, load: [1, 0.5, 0.25] },
|
||||
memory: { total: 16 * GB, available: 8 * GB, used: 8 * GB, swapTotal: 4 * GB, swapUsed: 0 },
|
||||
disks: [
|
||||
{
|
||||
device: "/dev/sdb2",
|
||||
mount: "/",
|
||||
type: "ext4",
|
||||
kind: "SSD",
|
||||
total: 200 * GB,
|
||||
used: 50 * GB,
|
||||
free: 150 * GB,
|
||||
},
|
||||
{
|
||||
device: "/dev/sda1",
|
||||
mount: "/data",
|
||||
type: "ext4",
|
||||
kind: "HDD",
|
||||
total: 500 * GB,
|
||||
used: 0,
|
||||
free: 500 * GB,
|
||||
},
|
||||
],
|
||||
homesMount: "/",
|
||||
gpus: [],
|
||||
usage: {
|
||||
cpu: 40,
|
||||
sampleMs: 1000,
|
||||
groups: [
|
||||
{
|
||||
key: "user:2",
|
||||
kind: "user",
|
||||
userId: 2,
|
||||
username: "mia",
|
||||
cpu: 25,
|
||||
memory: 2 * GB,
|
||||
memoryPercent: 12.5,
|
||||
processes: 3,
|
||||
top: [{ pid: 77, name: "claude", cpu: 20, memory: GB }],
|
||||
},
|
||||
{ key: "host", kind: "host", cpu: 5, memory: GB, memoryPercent: 6.3, processes: 90, top: [] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
host: { stats: vi.fn() },
|
||||
users: {
|
||||
list: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
storage: vi.fn(),
|
||||
tmux: vi.fn(async () => ({ available: false, users: [] })),
|
||||
avatarUrl: () => null,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...api,
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
accountPool: { list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { Administration } from "../Administration";
|
||||
|
||||
const renderAt = (url: string) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={[url]}>
|
||||
<Routes>
|
||||
<Route path="/admin" element={<Administration />} />
|
||||
<Route path="/" element={<div data-testid="home" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
api.host.stats.mockResolvedValue(stats);
|
||||
api.users.list.mockResolvedValue({
|
||||
users: [
|
||||
{
|
||||
id: 1,
|
||||
username: "boss",
|
||||
role: "admin",
|
||||
disabled: false,
|
||||
workspaces: 0,
|
||||
sessions: 0,
|
||||
last7d: { cost: 0, tokens: 0 },
|
||||
last30d: { cost: 0, tokens: 0 },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: "mia",
|
||||
role: "developer",
|
||||
displayName: "Mia",
|
||||
disabled: false,
|
||||
workspaces: 1,
|
||||
sessions: 2,
|
||||
last7d: { cost: 0, tokens: 0 },
|
||||
last30d: { cost: 0, tokens: 0 },
|
||||
},
|
||||
],
|
||||
});
|
||||
api.users.audit.mockResolvedValue({ entries: [] });
|
||||
api.users.storage.mockResolvedValue({
|
||||
storage: { 2: { bytes: 20 * GB, files: 10, partial: false, measuredAt: "x" } },
|
||||
measuring: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Administration", () => {
|
||||
it("sends local mode and a developer home", async () => {
|
||||
authState = { mode: "local" };
|
||||
renderAt("/admin");
|
||||
expect(await screen.findByTestId("home")).toBeTruthy();
|
||||
authState = { mode: "server", user: { id: 2, username: "mia", role: "developer" } };
|
||||
renderAt("/admin");
|
||||
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("opens on the Users tab", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
renderAt("/admin");
|
||||
expect(await screen.findByTestId("users-table")).toBeTruthy();
|
||||
expect(screen.getByRole("tab", { name: /users/i }).getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("shows hardware and each user's share on the Server tab", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
renderAt("/admin?tab=server");
|
||||
const table = await screen.findByTestId("server-usage-table");
|
||||
expect(await screen.findByText("box")).toBeTruthy();
|
||||
expect(screen.getByTestId("server-disks-table").textContent).toContain("HDD");
|
||||
const mia = await within(table).findByText("Mia");
|
||||
const row = mia.closest("tr")!;
|
||||
expect(row.textContent).toContain("25%");
|
||||
expect(row.textContent).toContain("(13%)");
|
||||
// 20 GB home on the 200 GB homes disk.
|
||||
expect(row.textContent).toContain("10%");
|
||||
expect(within(table).queryByText("claude")).toBeNull();
|
||||
fireEvent.click(mia.closest("button")!);
|
||||
expect(within(table).getByText("claude")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,11 @@
|
||||
* a plugin is picked from the list its marketplace link answers (a link the
|
||||
* server cannot read opens the by-hand form), a skill is uploaded from one
|
||||
* Markdown file under the name the user types, or written in the dialog.
|
||||
* A card's right-click menu runs the same actions (Preview, Delete).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Artifacts } from "../Artifacts";
|
||||
@@ -81,7 +82,13 @@ beforeEach(() => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({}),
|
||||
item({ id: 2, kind: "skill", title: "deploy", source: "deploy", files: [{ path: "SKILL.md", size: 3 }] }),
|
||||
item({
|
||||
id: 2,
|
||||
kind: "skill",
|
||||
title: "deploy",
|
||||
source: "deploy",
|
||||
files: [{ path: "SKILL.md", size: 3 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(confirmDialog).mockReset();
|
||||
@@ -145,7 +152,9 @@ describe("Adding", () => {
|
||||
await addMenu(user, "Skill");
|
||||
const md = new File(["# Steps"], "notes.md", { type: "text/markdown" });
|
||||
await user.upload(screen.getByTestId("skill-files"), md);
|
||||
expect(await screen.findByTestId("skill-shape")).toHaveTextContent("becomes the skill's SKILL.md");
|
||||
expect(await screen.findByTestId("skill-shape")).toHaveTextContent(
|
||||
"becomes the skill's SKILL.md"
|
||||
);
|
||||
const name = screen.getByLabelText(/Skill name/);
|
||||
await waitFor(() => expect(name).toHaveValue("notes"));
|
||||
await user.clear(name);
|
||||
@@ -229,4 +238,19 @@ describe("Artifacts", () => {
|
||||
expect(frame.getAttribute("sandbox")).toBe("");
|
||||
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html");
|
||||
});
|
||||
|
||||
it("opens a card's actions on right-click", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("report.html");
|
||||
fireEvent.contextMenu(screen.getByTestId("artifact-1"));
|
||||
const menu = screen.getByTestId("artifact-menu");
|
||||
expect(within(menu).getByRole("menuitem", { name: "Delete" })).toBeInTheDocument();
|
||||
expect(within(menu).queryByRole("menuitem", { name: "Install" })).toBeNull();
|
||||
await user.click(within(menu).getByRole("menuitem", { name: "Preview" }));
|
||||
expect(screen.queryByTestId("artifact-menu")).toBeNull();
|
||||
expect((await screen.findByTestId("artifact-preview")).getAttribute("src")).toBe(
|
||||
"/api/artifacts/1/raw?path=report.html"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
* with their own password typed (the confirm stays disabled until then), sees
|
||||
* a wrong password reported in the edit dialog, opens ONE Edit per row (no
|
||||
* separate role/password/workspace/disable buttons any more), and sees the
|
||||
* display name and email under a username.
|
||||
* display name and email under a username; right-clicking a row opens that
|
||||
* user's home in Files (an admin) — an analyst's menu only copies.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import type { AuthState, ServerUserUsage } from "../../lib/api";
|
||||
|
||||
let authState: AuthState = { mode: "local" };
|
||||
@@ -60,12 +61,17 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
|
||||
import { Users } from "../Users";
|
||||
|
||||
function FilesProbe() {
|
||||
return <div data-testid="files">{useLocation().search}</div>;
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/users"]}>
|
||||
<Routes>
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/" element={<div data-testid="home" />} />
|
||||
<Route path="/files" element={<FilesProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -192,4 +198,23 @@ describe("Users page", () => {
|
||||
expect(screen.getByText("@mia")).toBeTruthy();
|
||||
expect(screen.getByText("mia@x.vn")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens a row's menu on right-click: Open home for an admin, copy only for an analyst", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
renderPage();
|
||||
fireEvent.contextMenu((await screen.findByText("mia")).closest("tr")!);
|
||||
const menu = screen.getByTestId("users-row-menu");
|
||||
expect(within(menu).getByRole("menuitem", { name: "Copy username" })).toBeTruthy();
|
||||
fireEvent.click(within(menu).getByRole("menuitem", { name: "Open home in Files" }));
|
||||
expect((await screen.findByTestId("files")).textContent).toBe("?home=mia");
|
||||
});
|
||||
|
||||
it("gives an analyst's row menu no admin actions", async () => {
|
||||
authState = { mode: "server", user: { id: 3, username: "ana", role: "analyst" } };
|
||||
renderPage();
|
||||
fireEvent.contextMenu((await screen.findByText("mia")).closest("tr")!);
|
||||
const menu = screen.getByTestId("users-row-menu");
|
||||
expect(within(menu).queryByRole("menuitem", { name: "Open home in Files" })).toBeNull();
|
||||
expect(within(menu).queryByRole("menuitem", { name: "Edit" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.4 (rebuild client to match)
|
||||
UI build v2.8.6 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+35
-10
@@ -85,7 +85,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
|
||||
@@ -292,7 +292,7 @@ come `fromLane` another workspace the caller can see, of the same account
|
||||
(`403 EFOREIGN` otherwise). An upload is at most 500 MB (`413 ETOOBIG`, from
|
||||
`Content-Length` before a byte is stored), a zip at most 2 GB / 50,000 files.
|
||||
Under S3 a move/copy/delete needs the installed `write-as` to know
|
||||
`move`/`copy`: `409 EUPGRADE` until `sudo agenthub server install-system` is
|
||||
`move`/`copy`: `409 EUPGRADE` until `agenthub server install-system` is
|
||||
re-run.
|
||||
|
||||
#### File Explorer on a home
|
||||
@@ -311,7 +311,32 @@ the host account's home. Every dot entry at the TOP of a home (`.ssh`,
|
||||
refused for every read and write — also through a symlink, a download or a
|
||||
zip (`403 ESECRET`); dot files deeper down are ordinary. `:who` is `me`; an
|
||||
admin may also name another user — read-only (`403 EREADONLY` on every
|
||||
write), each read written to the audit log. A copy goes home ↔ workspace
|
||||
write), each read written to the audit log. The one exception is that user's
|
||||
**Trash**.
|
||||
|
||||
A home's Trash is its USER's one recycle bin, whatever place they deleted in:
|
||||
`GET …/trash` merges their home account's trash (a member's
|
||||
`~/.local/share/agenthub/trash/`) with their host trash
|
||||
(`<data dir>/trash/<user id>/`, what they deleted in a host-folder workspace)
|
||||
and tags each item `store: "home" | "host"`; `POST …/trash/restore` and
|
||||
`POST …/trash/purge` take `{id, store}` (`store` defaults to `home`;
|
||||
`{id: "all"}` empties every store). A host item whose workspace is gone
|
||||
answers `404 ENOFILE` (nowhere of that account to put it back). An admin may
|
||||
restore and purge in ANOTHER user's Trash too, each written to the audit log
|
||||
with the item id — so a member's deleted files can be restored or freed
|
||||
without waiting the 7 days. `GET /api/homes/trash` merges every Trash the
|
||||
caller may open into one list (`{items, errors, keepDays}`; an admin's is
|
||||
every user's, each other user's read audited): each item carries `home` (the
|
||||
`:who` its restore/purge goes to) and `owner`, and a user whose trash cannot
|
||||
be read is listed in `errors` rather than left out. The Files sidebar has ONE
|
||||
**Trash** entry: a user's opens their own Trash; an admin's opens on one
|
||||
folder per user (**My Trash** first, each counted from that merged list, an
|
||||
unreadable one showing its error), and opening a folder shows that user's
|
||||
Trash (path bar **Trash › user**, Up goes back to the folders). A trash that
|
||||
could not be read shows as an error rather than as empty, and deleting
|
||||
forever asks first.
|
||||
|
||||
A copy goes home ↔ workspace
|
||||
(`fromHome` on the workspace route, `fromLane` on the home one) when both are
|
||||
one Linux account; each side is checked under its own rule. A home is never a
|
||||
workspace. The **Files** page opens on `?home=me` by default.
|
||||
@@ -1042,15 +1067,15 @@ cannot collide on `index.lock`.
|
||||
#### Your git identity
|
||||
|
||||
```
|
||||
GET /api/me/git { account: "member"|"host", writable, ssh: { type, publicKey, fingerprint, terminal: "on"|"off"|"foreign" } | null (the first key), sshKeys: [{ type, publicKey, fingerprint }] (ed25519 first), sshTerminal, https: [{ protocol, host, username, hasToken }] }
|
||||
POST /api/me/git/ssh-key Make the SSH key of `type` — Body: { replace?, type?: "ed25519" | "rsa" } (409 EEXISTS when that TYPE exists, without replace; the other type is kept — an account may hold one of each, and every ssh git offers both) → 201, same shape
|
||||
DELETE /api/me/git/ssh-key Delete the key of `?type=ed25519|rsa`, or every key without it (404 ENOTFOUND, 400 EBADKEYTYPE) — also removes our terminal `core.sshCommand`
|
||||
PUT /api/me/git/ssh-terminal Use the key for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (ours first, then `~/.ssh/id_*`); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
|
||||
PUT /api/me/git/ssh-user The SSH login name for one host — Body: { host, user } — writes `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` to the account's `~/.gitconfig` (replacing ours for that host), so a plain `ssh://<host>/…` URL logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`); 400 EBADHOST / EBADSSHUSER. Listed as `sshUsers: [{host, user}]` in GET /api/me/git
|
||||
DELETE /api/me/git/ssh-user/:host Drop that rule (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
|
||||
GET /api/me/git { account: "member"|"host", writable, ssh: { type, publicKey, fingerprint, terminal: "on"|"off"|"foreign" } | null (the first key), sshKeys: [{ id, type, name, publicKey, fingerprint }] (default ed25519, default rsa, then named keys by name; `name` null for a default key, `id` = its file name), sshTerminal, sshUsers: [{ host, user, alias, key }], https: [{ protocol, host, username, hasToken }], author: { name, email }, defaultEmail }
|
||||
POST /api/me/git/ssh-key Make an SSH key — Body: { replace?, type?: "ed25519" | "rsa", name? } — without `name` the DEFAULT key of that type (`id_ed25519`/`id_rsa`), with `name` (lowercase letters, digits, `-`, ≤ 32; 400 EBADKEYNAME) one more beside it, `id_<type>_<name>` — for another AWS account or a second GitHub user. 409 EEXISTS when that key exists without replace, or the name is taken by a key of the other type; 409 ETOOMANYKEYS past 10 keys → 201, same shape
|
||||
DELETE /api/me/git/ssh-key Delete one key — `?id=<key id>` (400 EBADKEYNAME for a malformed id), or the default key of `?type=ed25519|rsa` — or every key without either (404 ENOTFOUND, 400 EBADKEYTYPE). A login naming a deleted key goes back to every key; our terminal `core.sshCommand` follows the keys and goes with the last one
|
||||
PUT /api/me/git/ssh-terminal Use the keys for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (every AgentHub key first, then `~/.ssh/id_*`; rewritten as keys are made or deleted while on); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
|
||||
PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key? } — writes `url."ssh://<user>@<host>/".insteadOf "ssh://<from>/"` to the account's `~/.gitconfig`, `<from>` = `alias` or the host itself (replacing ours for that host/alias), so `ssh://<from>/…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub.<from>.sshKey` there: a clone of a `ssh://<from>/…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. 400 EBADHOST / EBADSSHUSER
|
||||
DELETE /api/me/git/ssh-user/:from Drop the rule of that host or alias and its key link (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
|
||||
POST /api/me/git/https Save an https login — Body: { host, username, password }
|
||||
DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND)
|
||||
PUT /api/me/git/author Commit author — Body: { name, email } (400 EBADAUTHOR) — `git config --global user.name/email` as the account
|
||||
PUT /api/me/git/author Commit author — Body: { name, email } (400 EBADAUTHOR) — `git config --global user.name/email` as the account. A new SSH key is labelled with that email, else `defaultEmail` (`<username>@smartgift.vn`; `AGENTHUB_EMAIL_DOMAIN` overrides the domain), also returned by GET /api/me/git as the email form's suggestion
|
||||
```
|
||||
|
||||
`GET` also answers `author: { name, email }` (null when unset). A member's
|
||||
|
||||
+1
-1
@@ -104,7 +104,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
|
||||
| `agenthub uninstall [--yes]` | **Uninstall AgentHub** (`scripts/uninstall.js`; Settings → System has the same button). Without `--yes` it only lists what is there and exits 1. With it, in order: closes every workspace terminal, removes the systemd/launchd unit, stops every dashboard server, the status-area icon and workspace stacks, takes AgentHub's own entries out of `~/.claude/settings.json` (hooks, statusline; backup `settings.json.agenthub-uninstall.bak`), Codex `hooks.json` and Antigravity `hooks.json`, deletes the AI Proxy `$CODEX_HOME/<name>.config.toml` files, runs `claude plugin uninstall` for `agenthub@*` and every plugin of the `agenthub` marketplace and removes that marketplace (never a shared one), then deletes `~/.local/bin/agenthub`, `~/.config/agenthub/`, `~/.claude/.agenthub.json`, `~/.claude/agenthub-{proxies,routers,git-credentials,accounts,profiles}`, `agenthub-lanes/.state` and `.locks`, the plugin's cached copies and the data directory (database included). **Kept:** the AI tools' own conversations (`~/.claude/projects`, `~/.codex/sessions`, …), workspace worktrees and branches, anything in your repositories, config backups. A data directory that is the home, `CLAUDE_HOME`, a checkout or not recognisably AgentHub's is refused. A failing step is logged and the rest still run; log: `$TMPDIR/agenthub-uninstall-<uid>.log`. Needs no running server. Run inside a dashboard terminal, it closes that terminal last. |
|
||||
| `agenthub tray` | Show AgentHub's **status-area icon** now, in the foreground (`scripts/tray.js`): Open AgentHub, Quit AgentHub (a submenu asks again, then runs `agenthub stop`), Start AgentHub after a quit (`agenthub start`), Hide this icon. A production server already keeps the icon up on its own unless Settings → System turned it off; this command does not change that setting. Exits at once when an icon is already running |
|
||||
| `agenthub repl` (aliases `shell`, `i`) | Open the **interactive shell** — see [Interactive REPL](#interactive-repl) |
|
||||
| `agenthub server setup [--hostname name] [--admin name]` | Turn **server mode** on (logins for a shared dashboard): writes `AGENTHUB_MODE=server` (and `DASHBOARD_ALLOWED_HOSTS`) to the settings file and creates the first admin, asking for its password. Works with the server down; restart it afterwards. See [SERVER-MODE.md](SERVER-MODE.md) |
|
||||
| `agenthub server setup [--hostname name] [--admin name] [--no-system]` | Turn **server mode** on (logins for a shared dashboard): writes `AGENTHUB_MODE=server` (and `DASHBOARD_ALLOWED_HOSTS`) to the settings file and creates the first admin, asking for its password. On Linux it then installs **one Linux user per account** by default — runs `install-system` (one sudo; skipped when `/etc/agenthub/server.conf` already exists) and `users provision --all`; if that fails, server mode stays on and the error says how to finish. `--no-system` (and any non-Linux machine) keeps every pane on the one host account, as before; then restart the dashboard yourself. See [SERVER-MODE.md](SERVER-MODE.md) |
|
||||
| `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
|
||||
| `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared |
|
||||
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+50
-16
@@ -15,8 +15,9 @@ Local mode does not change at all. The login code stays dormant until
|
||||
> **What server mode separates, and what it does not.** In the dashboard and
|
||||
> its API, each developer sees only their own workspaces and the sessions,
|
||||
> prompts, events, tokens and cost under them (see [What each user sees](#what-each-user-sees)).
|
||||
> Until [one Linux user per account](#one-linux-user-per-account) is installed
|
||||
> (`agenthub server install-system`), every terminal pane runs as the one Linux
|
||||
> On Linux, `agenthub server setup` installs [one Linux user per account](#one-linux-user-per-account)
|
||||
> by default. Without it (`--no-system`, a machine that is not Linux, or an
|
||||
> install that failed), every terminal pane runs as the one Linux
|
||||
> account that runs the dashboard. An agent in a pane can then read every
|
||||
> user's folders, transcripts and the database straight from disk. Without it,
|
||||
> use server mode only with people you trust.
|
||||
@@ -27,12 +28,14 @@ On the machine that will host the dashboard, logged in as the account that
|
||||
runs it:
|
||||
|
||||
```bash
|
||||
# 1. Turn server mode on, allow the public name, and create the first admin
|
||||
# 1. Turn server mode on, allow the public name, create the first admin and,
|
||||
# on Linux, install one Linux user per account (one sudo; it moves the
|
||||
# dashboard to the system unit agenthub.service, which picks the mode up)
|
||||
agenthub server setup --hostname agenthub.example.com --admin boss
|
||||
# (asks for the admin's password twice; from a script: pipe it on stdin)
|
||||
|
||||
# 2. Restart the dashboard so it picks the mode up
|
||||
systemctl --user restart agenthub # or: agenthub start
|
||||
# 2. Log out and back in once, so your shell joins the agenthub groups
|
||||
# (with --no-system instead: systemctl --user restart agenthub)
|
||||
|
||||
# 3. Add the other people
|
||||
agenthub users add alice # developer
|
||||
@@ -42,7 +45,14 @@ agenthub users add carol --role analyst # reads everyone, works in their o
|
||||
`setup` writes `AGENTHUB_MODE=server` and `DASHBOARD_ALLOWED_HOSTS` into
|
||||
AgentHub's settings file (`~/.config/agenthub/settings.env`). The real
|
||||
environment and a checkout's `.env` still take priority over that file. Run
|
||||
setup again whenever you need to: it creates an admin only when none exists.
|
||||
setup again whenever you need to: it creates an admin only when none exists,
|
||||
runs `install-system` only while `/etc/agenthub/server.conf` is missing, and
|
||||
gives every user their Linux account (`users provision --all`, idempotent).
|
||||
The machine needs what [What a new machine needs](#what-a-new-machine-needs-before-install-system)
|
||||
lists first. If the install fails, server mode stays on with every pane on the
|
||||
host account, and the error says so: fix the cause and run
|
||||
`agenthub server install-system`, or choose that shared account on purpose
|
||||
with `agenthub server setup --no-system`.
|
||||
|
||||
## Put HTTPS in front of it
|
||||
|
||||
@@ -143,7 +153,21 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
written to the audit log: `agenthub users audit`, or `GET /api/users/audit`.
|
||||
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
|
||||
sessions, and the tokens and $ it used in the last 7 and 30 days — the
|
||||
Users page in the sidebar shows it, with the audit log under it.
|
||||
**Administration** page in the sidebar (`/admin`; the old `/users` link
|
||||
redirects there) shows it on its **Users** tab, with the audit log under it.
|
||||
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
|
||||
**Server** tab: the machine's hostname, OS, uptime, CPU model, cores and
|
||||
load, memory and swap, every real disk (HDD or SSD, how full; the one
|
||||
holding the users' homes is marked), any NVIDIA GPU, and each user's live
|
||||
share of it — CPU % of the whole machine (100 % = every core busy) and
|
||||
resident memory over a 1 s `/proc` sample, a process count, the busiest
|
||||
processes by their kernel name (never argv or env), and their home's size
|
||||
as a share of the homes disk. A process counts for a user when it runs as
|
||||
their Linux account (S3) or, for the host account, when it inherited a
|
||||
workspace's `AGENTHUB_LANE_ID` — so without S3 a user's share is only what
|
||||
runs in their workspaces' panes; the rest is **Host account** or
|
||||
**System**. Read-only (`server/lib/host-stats.js`); Linux only for the
|
||||
per-user part. The page re-reads every 5 s while shown.
|
||||
- `GET /api/users/storage` (admin/analyst) is the page's **Storage** column:
|
||||
how much disk each user's home takes (their Linux home under S3, their
|
||||
folder under `AGENTHUB_USERS_ROOT` otherwise; an admin with none reads
|
||||
@@ -687,10 +711,10 @@ a pane really is. Each case below is refused or bounded, with a test in
|
||||
through `/opt/agenthub/libexec/tmux-member` (root's, in sudoers): it runs
|
||||
the call with the binary of the member's running server (`/proc/<pid>/exe`,
|
||||
found from their socket), else with `TMUX_BIN`, so a newer tmux never asks
|
||||
an older server. Until `sudo agenthub server install-system` has installed
|
||||
an older server. Until `agenthub server install-system` has installed
|
||||
it, calls use `TMUX_BIN` directly, as before. A member's server keeps its
|
||||
version until their last pane closes; the next start runs `TMUX_BIN`.
|
||||
- **Users → Members' tmux** (admin/analyst read, admin acts): each member's
|
||||
- **Administration → Server → Members' tmux** (admin/analyst read, admin acts): each member's
|
||||
running server with its version beside the installed one (an *older than
|
||||
installed* badge), its sessions and whether an agent of theirs is working.
|
||||
**Restart** ends one member's server — every pane of theirs closes, their
|
||||
@@ -763,12 +787,21 @@ itself, once.
|
||||
`/opt/agenthub/libexec` by `install-system` only — an update of AgentHub does
|
||||
not touch root-owned files. When a release changes them (v2.7.7: `write-as`
|
||||
learned `move`/`copy` for the File Explorer; v2.8.4 added `tmux-member`),
|
||||
re-run it once:
|
||||
re-run it once, as the host account and **without** `sudo` in front (it asks
|
||||
for the sudo password itself; as root it refuses, and `sudo agenthub …` fails
|
||||
with `command not found` because sudo's `secure_path` has no `~/.local/bin`):
|
||||
|
||||
```bash
|
||||
sudo agenthub server install-system
|
||||
agenthub server install-system
|
||||
```
|
||||
|
||||
A member server that a newer tmux already reached before `tmux-member` was
|
||||
installed can stay wedged: every call on that socket — `tmux-member`'s too —
|
||||
prints `server exited unexpectedly`. If Administration → Server → Members' tmux shows it with
|
||||
no panes, stop that one server (its pid is the `ah-<name>` process running
|
||||
`tmux -S <USERS_ROOT>/<name>/.agenthub/tmux/sock`) with `sudo kill -9 <pid>`;
|
||||
the next start runs a fresh server on `TMUX_BIN`. Never `tmux kill-server`.
|
||||
|
||||
Until then the File Explorer's move, copy and delete in a member's folder
|
||||
answer `409 EUPGRADE` (listing, upload, new folder/file and zip work).
|
||||
The same re-run installs `agenthub-provision delete`, behind the Users page's
|
||||
@@ -803,9 +836,10 @@ again under the same name gets a NEW, empty Linux account and home.
|
||||
4. AgentHub installed for the host account, as the plugin or a checkout
|
||||
(`npm run setup && npm run build`), and running once
|
||||
(`agenthub start`).
|
||||
5. Server mode on, with the first admin:
|
||||
`agenthub server setup --hostname agenthub.example.com --admin boss`
|
||||
(see [Set it up](#set-it-up)).
|
||||
5. Server mode itself is NOT a step before this: `agenthub server setup`
|
||||
(see [Set it up](#set-it-up)) turns it on and then runs `install-system`
|
||||
and `users provision --all` below itself. Run the Install steps by hand only
|
||||
after a `setup --no-system` or a failed install, or to repeat one.
|
||||
6. The AI tools members will use, installed for the host account (the AI
|
||||
Tools page or by hand).
|
||||
7. The HTTPS reverse proxy (see [Put HTTPS in front of it](#put-https-in-front-of-it)).
|
||||
@@ -860,8 +894,8 @@ again changes nothing that is already in place):
|
||||
|
||||
What a new machine still needs by hand after it:
|
||||
|
||||
1. `agenthub users provision --all`: users created before S3 was on (the
|
||||
admin from `server setup` is one) have no Linux account yet.
|
||||
1. `agenthub users provision --all`: users created before S3 was on have no
|
||||
Linux account yet (`server setup` already ran this for you).
|
||||
2. Log out and back in once, so your own shell joins `agenthub` and
|
||||
`agenthub-proc` (the unit gets them on its own).
|
||||
3. `chmod o-rwx ~` for the host account if the installer warned: the database
|
||||
|
||||
@@ -112,7 +112,7 @@ Branch: `server-s3`. Commits happen only when the user asks.
|
||||
- Verify: an e2e against a fake upstream (header swapped, streaming kept, a dead pane's token refused, another member's profile refused).
|
||||
|
||||
### P2. Host setup (sudo; on this machine with the agh-test users)
|
||||
- [x] `sudo agenthub server install-system` is idempotent, has `--uninstall`, and does the following:
|
||||
- [x] `agenthub server install-system` is idempotent, has `--uninstall`, and does the following:
|
||||
- creates groups `agenthub` and `agenthub-members`;
|
||||
- creates `/srv/agenthub/users` (0751) and `/opt/agenthub/{tools,app,libexec,sbin}`;
|
||||
- writes the sudoers file and checks it with `visudo -c`;
|
||||
|
||||
+323
-4
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.7.7
|
||||
version: 2.8.5
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -3952,6 +3952,10 @@ components:
|
||||
enum:
|
||||
- true
|
||||
example: true
|
||||
archived:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'Only with `?archive=1`: the id of the session the pane held, now archived, or null when the pane had no known session (a shell).'
|
||||
LaneWorktreeCreateRequest:
|
||||
type: object
|
||||
required:
|
||||
@@ -4819,6 +4823,10 @@ components:
|
||||
email:
|
||||
type: string
|
||||
nullable: true
|
||||
avatarAt:
|
||||
type: string
|
||||
nullable: true
|
||||
description: When the avatar was last set (null = none)
|
||||
createdAt:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -11205,6 +11213,14 @@ paths:
|
||||
format: uuid
|
||||
description: Run id (RunHandle.id).
|
||||
example: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c
|
||||
- name: archive
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- '1'
|
||||
description: '`1` (the UI''s Stop) also archives the session the pane held, so it leaves its workspace''s lists. Omitted (the UI''s Sleep, the CLI) the session stays listed, to be resumed later.'
|
||||
responses:
|
||||
'200':
|
||||
description: Kill issued (or run already terminated).
|
||||
@@ -14282,6 +14298,94 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/avatar:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: getMyAvatar
|
||||
summary: The account's own picture (nosniff, sandbox CSP)
|
||||
responses:
|
||||
'200':
|
||||
description: The image (PNG, JPEG, GIF or WebP)
|
||||
'401':
|
||||
description: EAUTH
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOAVATAR
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
put:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: setMyAvatar
|
||||
summary: Set the account's picture from the raw image bytes (PNG/JPEG/GIF/WebP, at most 1 MB). Login cookie only, audited
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
responses:
|
||||
'200':
|
||||
description: Set
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MyAccount'
|
||||
'400':
|
||||
description: EBADIMAGE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: EAUTH — no login cookie (a pane token is refused)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'413':
|
||||
description: ETOOBIG
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
delete:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: removeMyAvatar
|
||||
summary: Remove the account's picture
|
||||
responses:
|
||||
'200':
|
||||
description: Removed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MyAccount'
|
||||
'401':
|
||||
description: EAUTH — no login cookie (a pane token is refused)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users:
|
||||
get:
|
||||
tags:
|
||||
@@ -14391,6 +14495,118 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/tmux:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: listMemberTmux
|
||||
summary: 'Each member''s own tmux server (one Linux user per account): its version beside the installed one, sessions, agents working (admin/analyst)'
|
||||
responses:
|
||||
'200':
|
||||
description: '{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]} — available false without S3'
|
||||
'403':
|
||||
description: EADMIN — a developer
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/tmux-restart:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: restartMemberTmux
|
||||
summary: End one member's tmux server — every pane of theirs closes; the next start runs the installed tmux (admin, own password again, audited)
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- currentPassword
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: '{user}'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOUSER
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: ETMUXSTUCK — the server did not stop (the message names the pid for root) / ENOS3
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/tmux-restart-idle:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: restartIdleMemberTmux
|
||||
summary: Restart every member server older than the installed tmux while no agent of theirs works (admin, own password again, audited per member)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- currentPassword
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: '{restarted: [username], skipped: [username — an agent working], failed: [{username, message}]}'
|
||||
'403':
|
||||
description: EREAUTH / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/avatar:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: getUserAvatar
|
||||
summary: One account's picture, for the sidebar's owner groups (admin/analyst)
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: The image (PNG, JPEG, GIF or WebP)
|
||||
'403':
|
||||
description: EADMIN — a developer
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOAVATAR
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}:
|
||||
get:
|
||||
tags:
|
||||
@@ -14741,6 +14957,10 @@ paths:
|
||||
type: number
|
||||
spend7d:
|
||||
type: number
|
||||
tokens5h:
|
||||
type: integer
|
||||
tokens7d:
|
||||
type: integer
|
||||
quota:
|
||||
type: object
|
||||
nullable: true
|
||||
@@ -14770,6 +14990,10 @@ paths:
|
||||
type: number
|
||||
spend7d:
|
||||
type: number
|
||||
tokens5h:
|
||||
type: integer
|
||||
tokens7d:
|
||||
type: integer
|
||||
quota:
|
||||
type: object
|
||||
nullable: true
|
||||
@@ -15761,12 +15985,12 @@ paths:
|
||||
operationId: getGitIdentity
|
||||
responses:
|
||||
'200':
|
||||
description: '{account, writable, ssh: {publicKey, fingerprint} | null, https: [...], author: {name, email}}'
|
||||
description: '{account, writable, ssh: {type, publicKey, fingerprint, terminal} | null, https: [...], author: {name, email}}'
|
||||
/api/me/git/ssh-key:
|
||||
post:
|
||||
tags:
|
||||
- Git identity
|
||||
summary: Make (or with `replace`, replace) the SSH key
|
||||
summary: Make (or with `replace`, replace) the default SSH key of `type`, or with `name` one more beside it
|
||||
operationId: generateSshKey
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -15778,17 +16002,112 @@ paths:
|
||||
properties:
|
||||
replace:
|
||||
type: boolean
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- ed25519
|
||||
- rsa
|
||||
name:
|
||||
type: string
|
||||
pattern: ^[a-z0-9][a-z0-9-]{0,31}$
|
||||
responses:
|
||||
'201':
|
||||
description: The identity.
|
||||
'400':
|
||||
description: EBADKEYNAME
|
||||
'409':
|
||||
description: EEXISTS (without replace, or the name is taken) / ETOOMANYKEYS
|
||||
delete:
|
||||
tags:
|
||||
- Git identity
|
||||
summary: Delete the SSH key
|
||||
summary: Delete one SSH key (?id=, or the default of ?type=ed25519|rsa), or every key
|
||||
operationId: deleteSshKey
|
||||
parameters:
|
||||
- name: type
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- ed25519
|
||||
- rsa
|
||||
- name: id
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The identity.
|
||||
/api/me/git/ssh-terminal:
|
||||
put:
|
||||
tags:
|
||||
- Git identity
|
||||
summary: Use the SSH key for git in the account's own terminal (core.sshCommand)
|
||||
operationId: setTerminalSsh
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- 'on'
|
||||
properties:
|
||||
'on':
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: The identity; `ssh.terminal` is on, off or foreign.
|
||||
'409':
|
||||
description: EFOREIGNSSH — ~/.gitconfig already sets its own core.sshCommand.
|
||||
/api/me/git/ssh-user:
|
||||
put:
|
||||
tags:
|
||||
- Git identity
|
||||
summary: 'An SSH login: url.<ssh://user@host/>.insteadOf ssh://<alias or host>/, optionally tied to one key'
|
||||
operationId: setSshUser
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- host
|
||||
- user
|
||||
properties:
|
||||
host:
|
||||
type: string
|
||||
user:
|
||||
type: string
|
||||
alias:
|
||||
type: string
|
||||
pattern: ^[a-z0-9][a-z0-9-]{0,62}$
|
||||
key:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'The identity; `sshUsers: [{host, user, alias, key}]`.'
|
||||
'400':
|
||||
description: EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY
|
||||
/api/me/git/ssh-user/{from}:
|
||||
delete:
|
||||
tags:
|
||||
- Git identity
|
||||
summary: Drop the SSH login of a host or alias (and its key link)
|
||||
operationId: deleteSshUser
|
||||
parameters:
|
||||
- name: from
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The identity.
|
||||
'404':
|
||||
description: ENOTFOUND
|
||||
/api/me/git/https:
|
||||
post:
|
||||
tags:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.4",
|
||||
"version": "2.8.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.4",
|
||||
"version": "2.8.6",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.4",
|
||||
"version": "2.8.6",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* a systemd `--user` unit everywhere else (`systemd-service.js`, which reports
|
||||
* itself unavailable off Linux). Both export the same API — and so does the
|
||||
* third, `systemd-system-service.js`, chosen while the server-mode SYSTEM unit
|
||||
* `sudo agenthub server install-system` wrote exists (pure fs check).
|
||||
* `agenthub server install-system` wrote exists (pure fs check).
|
||||
* Require THIS module, never one of the three directly.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @file systemd-system-service.js
|
||||
* @description Server mode with one Linux user per account (S3): the dashboard
|
||||
* runs as a SYSTEM unit, `/etc/systemd/system/agenthub.service`, written once
|
||||
* by `sudo agenthub server install-system` (scripts/server/install-system.sh).
|
||||
* by `agenthub server install-system` (scripts/server/install-system.sh).
|
||||
* The unit runs as the host account with group `agenthub` and
|
||||
* CAP_DAC_READ_SEARCH — the two things a `systemd --user` unit cannot give.
|
||||
*
|
||||
@@ -162,7 +162,7 @@ function installUserService(opts) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Removing the system unit is root's: `sudo agenthub server install-system --uninstall`. */
|
||||
/** Removing the system unit is root's: `agenthub server install-system --uninstall`. */
|
||||
function uninstallUserService() {
|
||||
return "foreign";
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* `POST /api/lanes/clone` (URL allowlist, name/parent checks, a clone that
|
||||
* becomes a workspace, the SSH key persisted into the clone, 409
|
||||
* ECREDENTIALS for a remote wanting a login), `/api/me/git` (key made once,
|
||||
* only the public half returned, 0600 on disk), and the member (S3) path of
|
||||
* only the public half returned, 0600 on disk; named keys beside the default
|
||||
* ones, SSH logins per alias tied to one key, which a clone then offers
|
||||
* alone), and the member (S3) path of
|
||||
* `server/lib/git-identity.js` with run-as and git stubbed — what reaches
|
||||
* git's argv, and that a login typed without "remember" leaves the member's
|
||||
* store as it was.
|
||||
@@ -246,6 +248,8 @@ describe("/api/me/git", () => {
|
||||
const made = await request("POST", "/api/me/git/ssh-key", {});
|
||||
assert.equal(made.status, 201);
|
||||
assert.match(made.body.ssh.publicKey, /^ssh-ed25519 /);
|
||||
// No commit author yet: the key is labelled with the default email.
|
||||
assert.match(made.body.ssh.publicKey, /@smartgift\.vn$/);
|
||||
assert.match(made.body.ssh.fingerprint, /^SHA256:/);
|
||||
assert.ok(!JSON.stringify(made.body).includes("PRIVATE"));
|
||||
const key = path.join(ROOT, ".claude", "agenthub-ssh", "id_ed25519");
|
||||
@@ -311,6 +315,7 @@ describe("/api/me/git", () => {
|
||||
it("reads and sets the commit author, refusing a bad one", async () => {
|
||||
const r0 = await request("GET", "/api/me/git");
|
||||
assert.deepEqual(r0.body.author, { name: null, email: null });
|
||||
assert.equal(r0.body.defaultEmail, `${os.userInfo().username}@smartgift.vn`);
|
||||
const bad = await request("PUT", "/api/me/git/author", { name: "a\nb", email: "x@y" });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADAUTHOR");
|
||||
@@ -318,6 +323,10 @@ describe("/api/me/git", () => {
|
||||
assert.equal(ok.status, 200);
|
||||
assert.deepEqual(ok.body.author, { name: "Vi", email: "vi@example.com" });
|
||||
assert.match(fs.readFileSync(path.join(ROOT, ".gitconfig"), "utf8"), /email = vi@example.com/);
|
||||
// A key made now is labelled with the author's email.
|
||||
const key = await request("POST", "/api/me/git/ssh-key", { replace: true });
|
||||
assert.match(key.body.ssh.publicKey, / vi@example\.com$/);
|
||||
await request("DELETE", "/api/me/git/ssh-key");
|
||||
});
|
||||
|
||||
it("saves an https login without ever returning the token", async () => {
|
||||
@@ -404,7 +413,9 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
|
||||
user: "APKAEXAMPLE1",
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE1" }]);
|
||||
assert.deepEqual(r.body.sshUsers, [
|
||||
{ host: HOST, user: "APKAEXAMPLE1", alias: null, key: null },
|
||||
]);
|
||||
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE1@${HOST}/v1/repos/x`);
|
||||
assert.equal(getUrl("ssh://other.test/x"), "ssh://other.test/x");
|
||||
// A second save replaces, never stacks a second rewrite.
|
||||
@@ -412,7 +423,9 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
|
||||
host: HOST,
|
||||
user: "APKAEXAMPLE2",
|
||||
});
|
||||
assert.deepEqual(again.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE2" }]);
|
||||
assert.deepEqual(again.body.sshUsers, [
|
||||
{ host: HOST, user: "APKAEXAMPLE2", alias: null, key: null },
|
||||
]);
|
||||
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE2@${HOST}/v1/repos/x`);
|
||||
const del = await request("DELETE", `/api/me/git/ssh-user/${HOST}`);
|
||||
assert.deepEqual(del.body.sshUsers, []);
|
||||
@@ -434,7 +447,9 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
|
||||
assert.equal(bad.body.error.code, "EBADSSHUSER");
|
||||
// Ours lists only our shape; a rule the user wrote for another host is kept.
|
||||
const r = await request("GET", "/api/me/git");
|
||||
assert.deepEqual(r.body.sshUsers, [{ host: "mine.test", user: "me" }]);
|
||||
assert.deepEqual(r.body.sshUsers, [
|
||||
{ host: "mine.test", user: "me", alias: null, key: null },
|
||||
]);
|
||||
assert.equal((await request("DELETE", "/api/me/git/ssh-user/other.test")).status, 404);
|
||||
assert.equal(getUrl("ssh://mine.test/x"), "ssh://me@mine.test/x");
|
||||
} finally {
|
||||
@@ -443,6 +458,122 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("several accounts: named keys and SSH logins per alias", () => {
|
||||
const getUrl = (url) =>
|
||||
execFileSync("git", ["ls-remote", "--get-url", url], { encoding: "utf8" }).trim();
|
||||
const HOST = "git-codecommit.us-east-1.amazonaws.com";
|
||||
|
||||
it("keeps named keys beside the default ones, and deletes one by id", async () => {
|
||||
await request("DELETE", "/api/me/git/ssh-key");
|
||||
const a = await request("POST", "/api/me/git/ssh-key", { type: "rsa", name: "AWS-Prod" });
|
||||
assert.equal(a.status, 201, JSON.stringify(a.body));
|
||||
const b = await request("POST", "/api/me/git/ssh-key", { type: "rsa", name: "aws-dev" });
|
||||
const d = await request("POST", "/api/me/git/ssh-key", {});
|
||||
assert.deepEqual(
|
||||
d.body.sshKeys.map((k) => [k.id, k.type, k.name]),
|
||||
[
|
||||
["id_ed25519", "ed25519", null],
|
||||
["id_rsa_aws-dev", "rsa", "aws-dev"],
|
||||
["id_rsa_aws-prod", "rsa", "aws-prod"],
|
||||
]
|
||||
);
|
||||
assert.notEqual(b.body.sshKeys[0].publicKey, a.body.sshKeys[0].publicKey);
|
||||
const dir = path.join(ROOT, ".claude", "agenthub-ssh");
|
||||
assert.equal(fs.statSync(path.join(dir, "id_rsa_aws-prod")).mode & 0o777, 0o600);
|
||||
// One name per key, whatever its type; a bad name is refused.
|
||||
const dup = await request("POST", "/api/me/git/ssh-key", { name: "aws-dev" });
|
||||
assert.equal(dup.status, 409);
|
||||
const bad = await request("POST", "/api/me/git/ssh-key", { name: "../x" });
|
||||
assert.equal(bad.body.error.code, "EBADKEYNAME");
|
||||
const gone = await request("DELETE", "/api/me/git/ssh-key?id=id_rsa_aws-dev");
|
||||
assert.deepEqual(
|
||||
gone.body.sshKeys.map((k) => k.id),
|
||||
["id_ed25519", "id_rsa_aws-prod"]
|
||||
);
|
||||
assert.equal((await request("DELETE", "/api/me/git/ssh-key?id=id_rsa_aws-dev")).status, 404);
|
||||
assert.equal((await request("DELETE", "/api/me/git/ssh-key?id=../x")).status, 400);
|
||||
// The terminal command names every key once switched on, and follows them.
|
||||
await request("PUT", "/api/me/git/ssh-terminal", { on: true });
|
||||
const cmd = () =>
|
||||
execFileSync("git", ["config", "--global", "core.sshCommand"], { encoding: "utf8" });
|
||||
assert.match(cmd(), /id_rsa_aws-prod'/);
|
||||
const again = await request("POST", "/api/me/git/ssh-key", { type: "rsa", name: "aws-dev" });
|
||||
assert.equal(again.body.sshTerminal, "on");
|
||||
assert.match(cmd(), /id_rsa_aws-dev'/);
|
||||
await request("PUT", "/api/me/git/ssh-terminal", { on: false });
|
||||
});
|
||||
|
||||
it("logs each alias in as its own user, and a clone of it uses that key alone", async () => {
|
||||
const prod = await request("PUT", "/api/me/git/ssh-user", {
|
||||
host: HOST,
|
||||
user: "APKAPROD",
|
||||
alias: "AWS-Prod",
|
||||
key: "id_rsa_aws-prod",
|
||||
});
|
||||
assert.equal(prod.status, 200, JSON.stringify(prod.body));
|
||||
await request("PUT", "/api/me/git/ssh-user", { host: HOST, user: "APKADEV", alias: "aws-dev" });
|
||||
await request("PUT", "/api/me/git/ssh-user", { host: HOST, user: "APKADEFAULT" });
|
||||
const rows = (await request("GET", "/api/me/git")).body.sshUsers;
|
||||
assert.deepEqual(
|
||||
rows.map((r) => [r.alias, r.user, r.key]).sort(),
|
||||
[
|
||||
["aws-dev", "APKADEV", null],
|
||||
["aws-prod", "APKAPROD", "id_rsa_aws-prod"],
|
||||
[null, "APKADEFAULT", null],
|
||||
].sort()
|
||||
);
|
||||
assert.equal(getUrl("ssh://aws-prod/v1/repos/x"), `ssh://APKAPROD@${HOST}/v1/repos/x`);
|
||||
assert.equal(getUrl("ssh://aws-dev/v1/repos/x"), `ssh://APKADEV@${HOST}/v1/repos/x`);
|
||||
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKADEFAULT@${HOST}/v1/repos/x`);
|
||||
// Refused: an alias with a dot (it could shadow a real host), a key that is not there.
|
||||
const dot = await request("PUT", "/api/me/git/ssh-user", {
|
||||
host: HOST,
|
||||
user: "A",
|
||||
alias: "a.b",
|
||||
});
|
||||
assert.equal(dot.body.error.code, "EBADALIAS");
|
||||
const nokey = await request("PUT", "/api/me/git/ssh-user", {
|
||||
host: HOST,
|
||||
user: "A",
|
||||
alias: "x",
|
||||
key: "id_rsa_nope",
|
||||
});
|
||||
assert.equal(nokey.body.error.code, "ENOKEY");
|
||||
|
||||
// The test forge stands in for CodeCommit: a longer insteadOf wins.
|
||||
const bare = path.join(ROOT, "bare.git");
|
||||
execFileSync("git", [
|
||||
"config",
|
||||
"--global",
|
||||
"--add",
|
||||
`url.file://${bare}.insteadOf`,
|
||||
"ssh://aws-prod/v1/repos/r",
|
||||
]);
|
||||
const r = await request("POST", "/api/lanes/clone", {
|
||||
url: "ssh://aws-prod/v1/repos/r",
|
||||
parent: WORK,
|
||||
name: "via-alias",
|
||||
});
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
const cmd = execFileSync(
|
||||
"git",
|
||||
["-C", path.join(WORK, "via-alias"), "config", "core.sshCommand"],
|
||||
{ encoding: "utf8" }
|
||||
).trim();
|
||||
assert.match(cmd, /^ssh -o IdentityFile='.*\/id_rsa_aws-prod' -o IdentitiesOnly=yes$/);
|
||||
|
||||
// Deleting the key unties the login; deleting the login drops the rule.
|
||||
const unkeyed = await request("DELETE", "/api/me/git/ssh-key?id=id_rsa_aws-prod");
|
||||
assert.equal(unkeyed.body.sshUsers.find((x) => x.alias === "aws-prod").key, null);
|
||||
for (const f of ["aws-prod", "aws-dev", HOST]) {
|
||||
assert.equal((await request("DELETE", `/api/me/git/ssh-user/${f}`)).status, 200);
|
||||
}
|
||||
assert.deepEqual((await request("GET", "/api/me/git")).body.sshUsers, []);
|
||||
assert.equal(getUrl("ssh://aws-dev/v1/repos/x"), "ssh://aws-dev/v1/repos/x");
|
||||
await request("DELETE", "/api/me/git/ssh-key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("git-identity, member account (S3, stubbed)", () => {
|
||||
const runAs = require("../lib/run-as");
|
||||
const worktree = require("../lib/worktree");
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* (also through a symlink, a download or a zip) while a nested dotfile is
|
||||
* fine, delete/restore work with no workspace, a copy goes home ↔ workspace
|
||||
* under each side's own rule — and in server mode a developer never reaches
|
||||
* another user's home while an admin gets it read-only.
|
||||
* another user's home while an admin gets it read-only — except its Trash,
|
||||
* which the admin lists, restores from and purges as the member's own.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -204,3 +205,143 @@ describe("homes in server mode", () => {
|
||||
assert.ok(homes.listHomes({ user: ann }).some((x) => x.id === "bob" && x.readOnly));
|
||||
});
|
||||
});
|
||||
|
||||
describe("an admin manages a member's Trash", () => {
|
||||
const auth = require("../lib/auth");
|
||||
const USERS = path.join(ROOT, "users");
|
||||
let cara;
|
||||
let dan;
|
||||
const call = (method, p, cookie, body) =>
|
||||
fetch(`${base}${p}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", cookie },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
const login = async (username, password) => {
|
||||
const r = await fetch(`${base}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return r.headers.get("set-cookie").split(";")[0];
|
||||
};
|
||||
before(async () => {
|
||||
fs.mkdirSync(path.join(USERS, "cara"), { recursive: true });
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
process.env.AGENTHUB_USERS_ROOT = USERS;
|
||||
await auth.createUser("cara", "cara-pass-1", "developer");
|
||||
await auth.createUser("dan", "dan-pass-1", "admin");
|
||||
cara = await login("cara", "cara-pass-1");
|
||||
dan = await login("dan", "dan-pass-1");
|
||||
});
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
delete process.env.AGENTHUB_USERS_ROOT;
|
||||
});
|
||||
|
||||
it("lists, restores and purges the member's own trash — the rest stays read-only", async () => {
|
||||
const C = "/api/homes/me/fs";
|
||||
const D = "/api/homes/cara/fs";
|
||||
for (const n of ["old", "junk"]) {
|
||||
assert.equal((await call("POST", `${C}/mkdir`, cara, { path: n })).status, 201);
|
||||
}
|
||||
assert.equal((await call("POST", `${C}/delete`, cara, { paths: ["old", "junk"] })).status, 200);
|
||||
|
||||
// The admin sees CARA's trash, not their own.
|
||||
let items = (await call("GET", `${D}/trash`, dan)).body.items;
|
||||
assert.deepEqual(items.map((i) => i.name).sort(), ["junk", "old"]);
|
||||
assert.equal((await call("GET", "/api/homes/me/fs/trash", dan)).body?.items?.length ?? 0, 0);
|
||||
|
||||
// …and every user's in one list, each item tagged with its home and owner.
|
||||
const all = (await call("GET", "/api/homes/trash", dan)).body;
|
||||
assert.deepEqual(
|
||||
all.items.map((i) => [i.name, i.home, i.owner]).sort(),
|
||||
[
|
||||
["junk", "cara", "cara"],
|
||||
["old", "cara", "cara"],
|
||||
]
|
||||
);
|
||||
// A developer's merged view is only their own.
|
||||
assert.deepEqual(
|
||||
(await call("GET", "/api/homes/trash", cara)).body.items.map((i) => i.home),
|
||||
["me", "me"]
|
||||
);
|
||||
const old = items.find((i) => i.name === "old");
|
||||
const r = await call("POST", `${D}/trash/restore`, dan, { id: old.id });
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.ok(fs.statSync(path.join(USERS, "cara", "old")).isDirectory());
|
||||
|
||||
assert.equal((await call("POST", `${D}/trash/purge`, dan, { id: "all" })).body.purged, 1);
|
||||
assert.equal((await call("GET", `${C}/trash`, cara)).body.items.length, 0);
|
||||
|
||||
// Everything else in another user's home is still refused.
|
||||
const w = await call("POST", `${D}/mkdir`, dan, { path: "nope" });
|
||||
assert.equal(w.status, 403);
|
||||
assert.equal(w.body.error.code, "EREADONLY");
|
||||
assert.equal((await call("POST", `${D}/delete`, dan, { paths: ["old"] })).status, 403);
|
||||
|
||||
// A developer still never reaches another user's trash.
|
||||
assert.equal((await call("GET", "/api/homes/dan/fs/trash", cara)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("one recycle bin per user", () => {
|
||||
const fx = require("../lib/file-explorer");
|
||||
const runAs = require("../lib/run-as");
|
||||
const MEMBER = path.join(ROOT, "member-home");
|
||||
const put = (root, id, name, laneId) => {
|
||||
fs.mkdirSync(path.join(root, id, "item"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, id, "meta.json"),
|
||||
JSON.stringify({
|
||||
id,
|
||||
name,
|
||||
laneId,
|
||||
rel: name,
|
||||
isDir: true,
|
||||
deletedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
it("has one store when the home is the host's", async () => {
|
||||
const out = await fx.listUserTrash(HOME, "solo");
|
||||
assert.deepEqual(out.items, []);
|
||||
put(path.join(process.env.DASHBOARD_DATA_DIR, "trash", "solo"), "solo0001", "x", null);
|
||||
const items = (await fx.listUserTrash(HOME, "solo")).items;
|
||||
assert.deepEqual(
|
||||
items.map((i) => [i.name, i.store]),
|
||||
[["x", "home"]]
|
||||
);
|
||||
assert.equal(
|
||||
(await fx.purgeUserTrash(HOME, "solo", "host", "solo0001").catch((e) => e)).code,
|
||||
"EBADPATH"
|
||||
);
|
||||
});
|
||||
|
||||
it("merges a member's home trash with their host trash, each item tagged", async (t) => {
|
||||
fs.mkdirSync(MEMBER, { recursive: true });
|
||||
const acct = { name: "mem", home: MEMBER, uid: process.getuid() };
|
||||
const orig = runAs.accountForPath;
|
||||
t.after(() => {
|
||||
runAs.accountForPath = orig;
|
||||
});
|
||||
runAs.accountForPath = (p) => (String(p).startsWith(MEMBER) ? acct : null);
|
||||
put(path.join(MEMBER, ".local", "share", "agenthub", "trash"), "memhome01", "fromHome", null);
|
||||
put(path.join(process.env.DASHBOARD_DATA_DIR, "trash", "77"), "memhost01", "fromHostWs", 5);
|
||||
const items = (await fx.listUserTrash(MEMBER, "77")).items;
|
||||
assert.deepEqual(items.map((i) => [i.name, i.store]).sort(), [
|
||||
["fromHome", "home"],
|
||||
["fromHostWs", "host"],
|
||||
]);
|
||||
// A host item whose workspace is gone has nowhere to go back to.
|
||||
const r = await fx
|
||||
.restoreUserTrash(MEMBER, "77", "host", "memhost01", () => null)
|
||||
.catch((e) => e);
|
||||
assert.equal(r.code, "ENOFILE");
|
||||
assert.equal((await fx.purgeUserTrash(MEMBER, "77", "host", "memhost01")).purged, 1);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "trash", "77", "memhost01"))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @file host-stats.test.js
|
||||
* @description Tests for server/lib/host-stats.js — the Server tab's hardware
|
||||
* snapshot and per-user CPU/memory attribution (`GET /api/host`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { hostStats, mountOf, __setDeps } = require("../lib/host-stats");
|
||||
|
||||
const linux = process.platform === "linux";
|
||||
|
||||
test("mountOf picks the longest mount point holding the path", () => {
|
||||
const list = [{ mount: "/" }, { mount: "/srv" }, { mount: "/srv/users" }];
|
||||
assert.equal(mountOf(list, "/srv/users/alice"), "/srv/users");
|
||||
assert.equal(mountOf(list, "/srv/usersx"), "/srv");
|
||||
assert.equal(mountOf(list, "/home/x"), "/");
|
||||
assert.equal(mountOf(list, null), null);
|
||||
});
|
||||
|
||||
test("hostStats reports hardware and attributes processes by uid", { skip: !linux }, async () => {
|
||||
const me = process.getuid();
|
||||
__setDeps({
|
||||
users: () => [
|
||||
{ id: 7, username: "zed" },
|
||||
{ id: 8, username: "idle" },
|
||||
],
|
||||
memberNameForUid: (uid) => (uid === me ? "zed" : null),
|
||||
laneOwner: () => null,
|
||||
homesRoot: () => "/",
|
||||
wait: () => new Promise((r) => setTimeout(r, 20)),
|
||||
});
|
||||
const r = await hostStats();
|
||||
assert.ok(r.cpu.cores > 0);
|
||||
assert.ok(r.memory.total > 0 && r.memory.used >= 0);
|
||||
assert.ok(Array.isArray(r.disks));
|
||||
const zed = r.usage.groups.find((g) => g.key === "user:7");
|
||||
assert.ok(zed.processes > 0, "this test's own process belongs to zed");
|
||||
assert.ok(zed.memory > 0);
|
||||
assert.ok(zed.top.every((t) => typeof t.name === "string" && !("argv" in t)));
|
||||
const idle = r.usage.groups.find((g) => g.key === "user:8");
|
||||
assert.deepEqual([idle.cpu, idle.memory, idle.processes], [0, 0, 0]);
|
||||
assert.equal(
|
||||
r.usage.groups.find((g) => g.key === "host"),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
"a host process with a workspace's AGENTHUB_LANE_ID counts for its owner",
|
||||
{ skip: !linux },
|
||||
async () => {
|
||||
const { spawn } = require("node:child_process");
|
||||
const child = spawn("sleep", ["5"], { env: { ...process.env, AGENTHUB_LANE_ID: "4242" } });
|
||||
try {
|
||||
__setDeps({
|
||||
users: () => [{ id: 3, username: "own" }],
|
||||
memberNameForUid: () => null,
|
||||
laneOwner: (id) => (id === 4242 ? 3 : null),
|
||||
wait: () => new Promise((r) => setTimeout(r, 20)),
|
||||
});
|
||||
const r = await hostStats();
|
||||
const own = r.usage.groups.find((g) => g.key === "user:3");
|
||||
assert.ok(own.top.some((t) => t.pid === child.pid));
|
||||
} finally {
|
||||
child.kill();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -129,6 +129,7 @@ const authRouter = require("./routes/auth");
|
||||
const meRouter = require("./routes/me");
|
||||
const accountsRouter = require("./routes/accounts");
|
||||
const usersRouter = require("./routes/users");
|
||||
const hostRouter = require("./routes/host");
|
||||
const { auditGuard } = require("./lib/audit");
|
||||
const { authGuard, isServerMode, hasAdmin } = require("./lib/auth");
|
||||
const { scopeGuard } = require("./lib/scope");
|
||||
@@ -179,6 +180,7 @@ function createApp() {
|
||||
app.use("/api/me", meRouter);
|
||||
app.use("/api/accounts", accountsRouter);
|
||||
app.use("/api/users", usersRouter);
|
||||
app.use("/api/host", hostRouter);
|
||||
|
||||
app.use("/api/sessions", sessionsRouter);
|
||||
app.use("/api/agents", agentsRouter);
|
||||
|
||||
@@ -1146,7 +1146,7 @@ async function installSkill(req, row, { replace = false } = {}) {
|
||||
);
|
||||
if (acct) {
|
||||
if (!runAs.writeAsCanMove()) {
|
||||
throw fail("EUPGRADE", "replacing needs the updated helper: run `sudo agenthub server install-system` once");
|
||||
throw fail("EUPGRADE", "replacing needs the updated helper: run `agenthub server install-system` once");
|
||||
}
|
||||
runAs.mkdirAs(acct, path.dirname(aside));
|
||||
await runAs.writeAsAsync(acct, target, ["move", aside]);
|
||||
|
||||
@@ -89,6 +89,8 @@ const STAFF_ONLY_PREFIXES = [
|
||||
"/alerts/rules",
|
||||
"/users",
|
||||
"/accounts",
|
||||
// The machine's hardware and every user's CPU/memory share.
|
||||
"/host",
|
||||
];
|
||||
// The run form's `/` autocomplete lists commands and skills.
|
||||
const MEMBER_READ_ALLOW = ["/cc-config/commands", "/cc-config/skills"];
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file The workspace File Explorer (`/api/lanes/:id/fs/*`): list a folder,
|
||||
* make a folder or an empty file, rename, move and copy (within a workspace
|
||||
* or into another of the same account), delete into a per-account TRASH that
|
||||
* can be restored (kept TRASH_DAYS), upload a file streamed from the request
|
||||
* can be restored (kept TRASH_DAYS) — and, for `/api/homes`, one recycle bin
|
||||
* per USER over every store their deletes went to — upload a file streamed from the request
|
||||
* into any (new) sub-folder, and download files or folders as one streamed zip.
|
||||
*
|
||||
* Every path is the read path's own (git-diff.js): `lanePath` (lexical, no
|
||||
@@ -17,7 +18,7 @@
|
||||
* Writes run as the folder's ACCOUNT (run-as.js): a member's through
|
||||
* `write-as` (sudo, as them — the host never writes in their home), the
|
||||
* host's directly. A move/copy/trash in a member's folder needs write-as's
|
||||
* `move`/`copy` (`EUPGRADE` until `sudo agenthub server install-system` has
|
||||
* `move`/`copy` (`EUPGRADE` until `agenthub server install-system` has
|
||||
* installed it). A copy or move never crosses accounts (`EFOREIGN`).
|
||||
*
|
||||
* A name that is taken is never silently replaced: `conflict` decides —
|
||||
@@ -140,7 +141,7 @@ function needMove(acct) {
|
||||
if (acct && !runAs.writeAsCanMove()) {
|
||||
throw bad(
|
||||
"EUPGRADE",
|
||||
"moving or copying in a member's folder needs the updated helper: run `sudo agenthub server install-system` once"
|
||||
"moving or copying in a member's folder needs the updated helper: run `agenthub server install-system` once"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -468,12 +469,17 @@ async function dropTrash(acct, idDir) {
|
||||
async function listTrash(dir, userKey) {
|
||||
const root = await laneRoot(dir);
|
||||
const acct = accountAt(root);
|
||||
const tr = trashRoot(acct, userKey);
|
||||
const items = await readStore(acct, trashRoot(acct, userKey));
|
||||
return { items, keepDays: TRASH_DAYS };
|
||||
}
|
||||
|
||||
/** The live items of one trash folder `tr` (of `acct`), expired ones dropped. */
|
||||
async function readStore(acct, tr, store) {
|
||||
let ids = [];
|
||||
try {
|
||||
ids = (await fs.promises.readdir(tr)).filter((n) => TRASH_ID_RE.test(n));
|
||||
} catch {
|
||||
return { items: [] };
|
||||
return [];
|
||||
}
|
||||
const cutoff = Date.now() - TRASH_DAYS * 86_400_000;
|
||||
const items = [];
|
||||
@@ -490,15 +496,20 @@ async function listTrash(dir, userKey) {
|
||||
path: meta.rel,
|
||||
isDir: !!meta.isDir,
|
||||
deletedAt: meta.deletedAt,
|
||||
...(store ? { store } : {}),
|
||||
});
|
||||
}
|
||||
items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
|
||||
return { items, keepDays: TRASH_DAYS };
|
||||
return items;
|
||||
}
|
||||
|
||||
function trashEntry(acct, userKey, id) {
|
||||
return entryAt(acct, trashRoot(acct, userKey), id);
|
||||
}
|
||||
|
||||
function entryAt(acct, tr, id) {
|
||||
if (!TRASH_ID_RE.test(String(id || ""))) throw bad("EBADPATH", "no such trash item");
|
||||
const idDir = path.join(trashRoot(acct, userKey), id);
|
||||
const idDir = path.join(tr, id);
|
||||
const meta = readMeta(acct, idDir);
|
||||
if (!meta) throw bad("ENOFILE", "no such trash item");
|
||||
return { idDir, meta };
|
||||
@@ -515,6 +526,11 @@ async function restore(dir, id, userKey, resolveLane) {
|
||||
const acct = accountAt(root);
|
||||
const { idDir, meta } = trashEntry(acct, userKey, id);
|
||||
const home = (meta.laneId != null && resolveLane(meta.laneId)) || dir;
|
||||
return putBack(acct, idDir, meta, home, dir);
|
||||
}
|
||||
|
||||
/** Move trash entry `idDir` back under folder `home` (`dir`: the fallback). */
|
||||
async function putBack(acct, idDir, meta, home, dir) {
|
||||
const parentRel = path.posix.dirname(String(meta.rel || meta.name));
|
||||
const relDir = parentRel === "." ? "" : parentRel;
|
||||
await ensureFolders(home, relDir);
|
||||
@@ -530,6 +546,77 @@ async function restore(dir, id, userKey, resolveLane) {
|
||||
return { path: relDir ? `${relDir}/${name}` : name, laneId: home === dir ? null : meta.laneId };
|
||||
}
|
||||
|
||||
// ── A user's recycle bin ───────────────────────────────────────────────────
|
||||
//
|
||||
// One Trash per USER, whatever place a delete happened in: the trash of their
|
||||
// home's account (a member's `~/.local/share/agenthub/trash/`, or the host's
|
||||
// `<data dir>/trash/<user id>/` when their home is the host's) plus — when
|
||||
// that is another folder — the host trash under their user id, where what
|
||||
// they deleted in a host-folder workspace went. Items carry `store`
|
||||
// ("home" | "host") so a restore or purge finds the right one.
|
||||
|
||||
/** The trash stores of the user `userKey` whose home is `homeDir`. */
|
||||
function userStores(homeDir, userKey) {
|
||||
const acct = accountAt(homeDir);
|
||||
const out = [{ store: "home", acct, root: trashRoot(acct, userKey) }];
|
||||
const host = trashRoot(null, userKey);
|
||||
if (acct || path.resolve(host) !== path.resolve(out[0].root)) {
|
||||
out.push({ store: "host", acct: null, root: host });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickStore(homeDir, userKey, store) {
|
||||
const s = userStores(homeDir, userKey).find((x) => x.store === (store || "home"));
|
||||
if (!s) throw bad("EBADPATH", "no such trash");
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Every item of the user's recycle bin, newest first. */
|
||||
async function listUserTrash(homeDir, userKey) {
|
||||
const items = [];
|
||||
for (const s of userStores(homeDir, userKey)) {
|
||||
items.push(...(await readStore(s.acct, s.root, s.store)));
|
||||
}
|
||||
items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
|
||||
return { items, keepDays: TRASH_DAYS };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore item `id` of the user's `store`: into the workspace it came from
|
||||
* when `resolveLane` still gives one, else — for the home store — into the
|
||||
* home. A host-store item whose workspace is gone has nowhere of the same
|
||||
* account to go (`ENOFILE`).
|
||||
*/
|
||||
async function restoreUserTrash(homeDir, userKey, store, id, resolveLane) {
|
||||
const s = pickStore(homeDir, userKey, store);
|
||||
const { idDir, meta } = entryAt(s.acct, s.root, id);
|
||||
const fallback = s.store === "home" ? homeDir : null;
|
||||
const home = (meta.laneId != null && resolveLane(meta.laneId)) || fallback;
|
||||
if (!home) {
|
||||
throw bad("ENOFILE", "the workspace this came from is gone — there is nowhere to restore it");
|
||||
}
|
||||
return putBack(s.acct, idDir, meta, home, fallback);
|
||||
}
|
||||
|
||||
/** Delete item `id` of `store` forever, or `"all"` of every store. */
|
||||
async function purgeUserTrash(homeDir, userKey, store, id) {
|
||||
if (id === "all") {
|
||||
let n = 0;
|
||||
for (const s of userStores(homeDir, userKey)) {
|
||||
for (const it of await readStore(s.acct, s.root)) {
|
||||
await dropTrash(s.acct, path.join(s.root, it.id));
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return { purged: n };
|
||||
}
|
||||
const s = pickStore(homeDir, userKey, store);
|
||||
const { idDir } = entryAt(s.acct, s.root, id);
|
||||
await dropTrash(s.acct, idDir);
|
||||
return { purged: 1 };
|
||||
}
|
||||
|
||||
async function purgeTrash(dir, userKey, id) {
|
||||
const root = await laneRoot(dir);
|
||||
const acct = accountAt(root);
|
||||
@@ -800,6 +887,9 @@ module.exports = {
|
||||
remove,
|
||||
trashOne,
|
||||
listTrash,
|
||||
listUserTrash,
|
||||
restoreUserTrash,
|
||||
purgeUserTrash,
|
||||
restore,
|
||||
purgeTrash,
|
||||
upload,
|
||||
|
||||
+222
-43
@@ -23,8 +23,14 @@
|
||||
* A plain `git` typed in a terminal reads none of that, so the account's own
|
||||
* `~/.gitconfig` may also carry our `core.sshCommand` (`setTerminalSsh`: on
|
||||
* by default for a member, a switch for the host; a user's own is never
|
||||
* overwritten), plus a per-host SSH login name (`setSshUser`: AWS CodeCommit
|
||||
* wants the IAM SSH key ID as the user) as a `url.<…>.insteadOf` rule there.
|
||||
* overwritten), plus SSH logins (`setSshUser`: AWS CodeCommit wants the IAM
|
||||
* SSH key ID as the user) as `url.<…>.insteadOf` rules there — one per host,
|
||||
* or one per ALIAS (`ssh://aws-prod/…`), so several accounts on one host
|
||||
* (two AWS accounts, two GitHub users) each log in as themselves. Besides
|
||||
* the default key of each type, an account may hold named keys
|
||||
* (`ssh/id_<type>_<name>`), and a login may name the key it uses
|
||||
* (`agenthub.<from>.sshKey` in the same file): a clone of that login's URL
|
||||
* offers ONLY that key and keeps it in the clone's config.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -74,6 +80,12 @@ function accountForRequest(req) {
|
||||
*/
|
||||
const KEY_FILES = { ed25519: "id_ed25519", rsa: "id_rsa" };
|
||||
const KEY_PREFIX = { ed25519: "ssh-ed25519", rsa: "ssh-rsa" };
|
||||
/** A named key's name, and every key's id (= its file name in `sshDir`). */
|
||||
const KEY_NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
||||
const KEY_ID_RE = /^id_(ed25519|rsa)(?:_([a-z0-9][a-z0-9-]{0,31}))?$/;
|
||||
/** ssh offers every key in turn and servers stop after ~6 tries. */
|
||||
const MAX_KEYS = 10;
|
||||
const keyIdFor = (type, name) => (name ? `${KEY_FILES[type]}_${name}` : KEY_FILES[type]);
|
||||
|
||||
function paths(account) {
|
||||
if (account.kind === "member") {
|
||||
@@ -214,17 +226,32 @@ function fingerprint(publicKey) {
|
||||
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
|
||||
}
|
||||
|
||||
/** `[{type, file, publicKey}]` of the account's keys, ed25519 first. */
|
||||
/**
|
||||
* `[{id, type, name, file, publicKey}]` of the account's keys: the default
|
||||
* ed25519 and rsa ones first (`name` null), then the named ones by name.
|
||||
*/
|
||||
function currentKeys(account) {
|
||||
const { sshDir } = paths(account);
|
||||
let ids;
|
||||
try {
|
||||
ids = fs.readdirSync(sshDir).filter((n) => KEY_ID_RE.test(n));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const rank = (id) => {
|
||||
const i = Object.values(KEY_FILES).indexOf(id);
|
||||
return i < 0 ? Object.keys(KEY_FILES).length : i;
|
||||
};
|
||||
ids.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
||||
const keys = [];
|
||||
for (const type of Object.keys(KEY_FILES)) {
|
||||
const file = path.join(sshDir, KEY_FILES[type]);
|
||||
for (const id of ids) {
|
||||
const [, type, name = null] = KEY_ID_RE.exec(id);
|
||||
const file = path.join(sshDir, id);
|
||||
const publicKey = readText(account, `${file}.pub`);
|
||||
if (!publicKey || !publicKey.startsWith(`${KEY_PREFIX[type]} `)) continue;
|
||||
// The private half is only looked at, never read into this process.
|
||||
if (!isOwnFile(account, file)) continue;
|
||||
keys.push({ type, file, publicKey: publicKey.trim() });
|
||||
keys.push({ id, type, name, file, publicKey: publicKey.trim() });
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -235,10 +262,12 @@ const keyFiles = (account) => currentKeys(account).map((k) => k.file);
|
||||
/** ssh options naming `files` — `-i` each, in order. */
|
||||
const identityArgs = (files) => files.map((f) => ` -i ${shq(f)}`).join("");
|
||||
|
||||
/** `[{type, publicKey, fingerprint}]` of the account's keys — never a private half. */
|
||||
/** `[{id, type, name, publicKey, fingerprint}]` of the account's keys — never a private half. */
|
||||
function sshKeysInfo(account) {
|
||||
return currentKeys(account).map((k) => ({
|
||||
id: k.id,
|
||||
type: k.type,
|
||||
name: k.name,
|
||||
publicKey: k.publicKey,
|
||||
fingerprint: fingerprint(k.publicKey),
|
||||
}));
|
||||
@@ -248,14 +277,30 @@ function sshKeysInfo(account) {
|
||||
const sshKeyInfo = (account) => sshKeysInfo(account)[0] ?? null;
|
||||
|
||||
/**
|
||||
* Make the account's key of `type` (ed25519, the default, or rsa); an
|
||||
* existing one OF THAT TYPE is replaced only with `replace`. A key of the
|
||||
* other type is left alone — a host refusing one type keeps the other.
|
||||
* Make the account's key of `type` (ed25519, the default, or rsa) — the
|
||||
* default one of that type, or with `name` one more beside it (another AWS
|
||||
* account, another GitHub user). An existing key of that id is replaced only
|
||||
* with `replace`; every other key is left alone.
|
||||
*/
|
||||
async function generateSshKey(account, { replace = false, comment, type = "ed25519" } = {}) {
|
||||
async function generateSshKey(
|
||||
account,
|
||||
{ replace = false, comment, type = "ed25519", name = null } = {}
|
||||
) {
|
||||
if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
|
||||
if (currentKeys(account).some((k) => k.type === type) && !replace) {
|
||||
throw err("EEXISTS", `an ${type} SSH key already exists — replace it explicitly`);
|
||||
const keyName = name == null || name === "" ? null : String(name).trim().toLowerCase();
|
||||
if (keyName !== null && !KEY_NAME_RE.test(keyName)) {
|
||||
throw err("EBADKEYNAME", "a key name of lowercase letters, digits and -, up to 32");
|
||||
}
|
||||
const id = keyIdFor(type, keyName);
|
||||
const keys = currentKeys(account);
|
||||
if (keys.some((k) => k.id === id) && !replace) {
|
||||
throw err("EEXISTS", `the SSH key ${keyName ?? type} already exists — replace it explicitly`);
|
||||
}
|
||||
if (keyName !== null && keys.some((k) => k.name === keyName && k.id !== id)) {
|
||||
throw err("EEXISTS", `a key named ${keyName} already exists`);
|
||||
}
|
||||
if (!keys.some((k) => k.id === id) && keys.length >= MAX_KEYS) {
|
||||
throw err("ETOOMANYKEYS", `at most ${MAX_KEYS} SSH keys`);
|
||||
}
|
||||
const label =
|
||||
String(comment || "agenthub")
|
||||
@@ -264,29 +309,44 @@ async function generateSshKey(account, { replace = false, comment, type = "ed255
|
||||
const { privateKey, publicKey } =
|
||||
type === "rsa" ? await makeRsaKeyPair(label) : makeKeyPair(label);
|
||||
const { sshDir } = paths(account);
|
||||
const file = path.join(sshDir, KEY_FILES[type]);
|
||||
const file = path.join(sshDir, id);
|
||||
writeText(account, file, privateKey, { secret: true });
|
||||
writeText(account, `${file}.pub`, publicKey, { secret: false });
|
||||
// A member's home is AgentHub's to set up: their terminal git uses the key
|
||||
// too, unless they wrote a core.sshCommand of their own. The host's is
|
||||
// its admin's choice (the Settings switch).
|
||||
if (account.kind === "member") await setTerminalSsh(account, true).catch(() => {});
|
||||
return sshKeysInfo(account).find((k) => k.type === type) ?? null;
|
||||
// its admin's choice (the Settings switch) — once on, it follows the keys.
|
||||
if (account.kind === "member" || (await terminalSshState(account)) === "on") {
|
||||
await setTerminalSsh(account, true).catch(() => {});
|
||||
}
|
||||
return sshKeysInfo(account).find((k) => k.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the account's key of `type`, or every key when `type` is omitted;
|
||||
* false when there was none. The terminal sshCommand goes with the last key.
|
||||
* Delete one key — `{id}`, or the DEFAULT key of `{type}` — or every key when
|
||||
* neither is given; false when there was none. A login naming a deleted key
|
||||
* goes back to offering every key; the terminal sshCommand follows the keys
|
||||
* and goes with the last one.
|
||||
*/
|
||||
async function removeSshKey(account, type) {
|
||||
async function removeSshKey(account, which) {
|
||||
const { type, id } = typeof which === "string" ? { type: which } : which || {};
|
||||
if (type != null && !KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
|
||||
const gone = currentKeys(account).filter((k) => type == null || k.type === type);
|
||||
if (id != null && !KEY_ID_RE.test(id)) throw err("EBADKEYNAME", "no such key id");
|
||||
const gone = currentKeys(account).filter((k) =>
|
||||
id != null ? k.id === id : type != null ? k.id === KEY_FILES[type] : true
|
||||
);
|
||||
if (!gone.length) return false;
|
||||
for (const k of gone) {
|
||||
removeFile(account, k.file);
|
||||
removeFile(account, `${k.file}.pub`);
|
||||
}
|
||||
const ids = new Set(gone.map((k) => k.id));
|
||||
for (const [from, key] of await keyLinks(account)) {
|
||||
if (ids.has(key)) await unlinkKey(account, from);
|
||||
}
|
||||
if (!currentKeys(account).length) await setTerminalSsh(account, false).catch(() => {});
|
||||
else if ((await terminalSshState(account)) === "on") {
|
||||
await setTerminalSsh(account, true).catch(() => {});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -386,6 +446,15 @@ async function getAuthor(account) {
|
||||
return { name: await get("user.name"), email: await get("user.email") };
|
||||
}
|
||||
|
||||
/** The domain of a suggested email when the account has no author yet. */
|
||||
const DEFAULT_EMAIL_DOMAIN = process.env.AGENTHUB_EMAIL_DOMAIN || "smartgift.vn";
|
||||
|
||||
/** `<username>@<DEFAULT_EMAIL_DOMAIN>`, or null without a usable name. */
|
||||
function defaultEmail(username) {
|
||||
const local = String(username || "").replace(/[^\w.+-]/g, "");
|
||||
return local ? `${local}@${DEFAULT_EMAIL_DOMAIN}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the account's commit author. Neither is a secret, so they may cross
|
||||
* sudo's argv; each is one line, a name up to 100 and an email up to 254
|
||||
@@ -418,11 +487,28 @@ async function setAuthor(account, { name, email }) {
|
||||
*/
|
||||
function terminalSshCommand(account) {
|
||||
const { sshDir } = paths(account);
|
||||
const ours = Object.values(KEY_FILES).map((f) => `-o IdentityFile=${shq(path.join(sshDir, f))}`);
|
||||
const files = new Set([
|
||||
...Object.values(KEY_FILES).map((f) => path.join(sshDir, f)),
|
||||
...currentKeys(account).map((k) => k.file),
|
||||
]);
|
||||
const ours = [...files].map((f) => `-o IdentityFile=${shq(f)}`);
|
||||
const defaults = ["id_ed25519", "id_rsa", "id_ecdsa"].map((f) => `-o IdentityFile=~/.ssh/${f}`);
|
||||
return ["ssh", ...ours, ...defaults].join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `cmd` is a terminal sshCommand WE wrote — of our shape, naming
|
||||
* key files in this account's AgentHub folder (any set of them: it follows
|
||||
* the keys as they are made and deleted) and nothing else but the defaults.
|
||||
*/
|
||||
function isOurTerminal(account, cmd) {
|
||||
const m = /^ssh((?: -o IdentityFile=(?:'[^']*'|~\/\.ssh\/id_[a-z0-9]+))+)$/.exec(cmd || "");
|
||||
if (!m) return false;
|
||||
const files = [...m[1].matchAll(/IdentityFile='([^']*)'/g)].map((x) => x[1]);
|
||||
const { sshDir } = paths(account);
|
||||
return files.length > 0 && files.every((f) => path.dirname(f) === sshDir);
|
||||
}
|
||||
|
||||
/** The account's global `core.sshCommand`, or "" when unset. */
|
||||
async function globalSshCommand(account) {
|
||||
const { git } = require("./worktree");
|
||||
@@ -440,7 +526,7 @@ async function globalSshCommand(account) {
|
||||
async function terminalSshState(account) {
|
||||
const cmd = await globalSshCommand(account);
|
||||
if (!cmd) return "off";
|
||||
return cmd === terminalSshCommand(account) ? "on" : "foreign";
|
||||
return isOurTerminal(account, cmd) ? "on" : "foreign";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -455,7 +541,8 @@ async function setTerminalSsh(account, on) {
|
||||
if (state === "foreign") {
|
||||
throw err("EFOREIGNSSH", "your ~/.gitconfig already sets core.sshCommand — left as it is");
|
||||
}
|
||||
if (state === "off") {
|
||||
// Off, or ours naming another set of keys than the account holds now.
|
||||
if (state === "off" || (await globalSshCommand(account)) !== terminalSshCommand(account)) {
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
@@ -478,9 +565,47 @@ async function setTerminalSsh(account, on) {
|
||||
// Clone dialog's and the git bar's — without touching `~/.ssh/config`. The
|
||||
// user name is no secret (an ID, not a key), so it may cross sudo's argv.
|
||||
const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
|
||||
// An alias has no dot, so it can never shadow a real host name.
|
||||
const ALIAS_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
const insteadOfBase = (host, user) => `ssh://${user}@${host}/`;
|
||||
|
||||
/** `[{host, user}]` — only the rules of exactly our shape; any other insteadOf is the user's. */
|
||||
/** `Map<from, key id>`: the key each login names (`agenthub.<from>.sshKey`). */
|
||||
async function keyLinks(account) {
|
||||
const { git } = require("./worktree");
|
||||
const out = await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--get-regexp",
|
||||
"^agenthub\\..*\\.sshkey$",
|
||||
]).then(
|
||||
(r) => r.stdout,
|
||||
() => ""
|
||||
);
|
||||
const links = new Map();
|
||||
for (const line of String(out).split("\n")) {
|
||||
const m = /^agenthub\.(.+)\.sshkey (\S+)$/.exec(line);
|
||||
if (m && KEY_ID_RE.test(m[2])) links.set(m[1], m[2]);
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
async function unlinkKey(account, from) {
|
||||
const { git } = require("./worktree");
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--unset-all",
|
||||
`agenthub.${from}.sshKey`,
|
||||
]).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* `[{host, user, alias, key}]` — only the rules of exactly our shape; any
|
||||
* other insteadOf is the user's. `alias` is null for the host's own rule
|
||||
* (`ssh://<host>/` logs in as `user`), else the name standing for that
|
||||
* user@host (`ssh://<alias>/`); `key` is the key id the login uses, or null
|
||||
* for every key.
|
||||
*/
|
||||
async function listSshUsers(account) {
|
||||
const { git } = require("./worktree");
|
||||
const out = await git(homeOf(account), [
|
||||
@@ -492,21 +617,35 @@ async function listSshUsers(account) {
|
||||
(r) => r.stdout,
|
||||
() => ""
|
||||
);
|
||||
const links = await keyLinks(account);
|
||||
const rows = [];
|
||||
for (const line of out.split("\n")) {
|
||||
const m = /^url\.ssh:\/\/([^@/\s]+)@([^/\s]+)\/\.insteadof ssh:\/\/([^/\s]+)\/$/.exec(line);
|
||||
if (m && m[2] === m[3] && SSH_USER_RE.test(m[1]) && HOST_RE.test(m[2])) {
|
||||
rows.push({ host: m[2], user: m[1] });
|
||||
if (
|
||||
m &&
|
||||
(m[2] === m[3] || ALIAS_RE.test(m[3])) &&
|
||||
SSH_USER_RE.test(m[1]) &&
|
||||
HOST_RE.test(m[2])
|
||||
) {
|
||||
rows.push({
|
||||
host: m[2],
|
||||
user: m[1],
|
||||
alias: m[2] === m[3] ? null : m[3],
|
||||
key: links.get(m[3]) ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Drop our rule for `host` (whatever its user); false when there was none. */
|
||||
async function removeSshUser(account, host) {
|
||||
/**
|
||||
* Drop our rule for `from` — a host's own rule, or an alias's — and the key
|
||||
* it names; false when there was none.
|
||||
*/
|
||||
async function removeSshUser(account, from) {
|
||||
const { git } = require("./worktree");
|
||||
const h = String(host || "").toLowerCase();
|
||||
const rows = (await listSshUsers(account)).filter((r) => r.host === h);
|
||||
const f = String(from || "").toLowerCase();
|
||||
const rows = (await listSshUsers(account)).filter((r) => (r.alias ?? r.host) === f);
|
||||
for (const r of rows) {
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
@@ -514,34 +653,68 @@ async function removeSshUser(account, host) {
|
||||
"--fixed-value",
|
||||
"--unset-all",
|
||||
`url.${insteadOfBase(r.host, r.user)}.insteadOf`,
|
||||
`ssh://${r.host}/`,
|
||||
`ssh://${f}/`,
|
||||
]);
|
||||
}
|
||||
if (rows.length) await unlinkKey(account, f);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** Log in to `host` over SSH as `user` (replaces our earlier rule for that host). */
|
||||
async function setSshUser(account, { host, user } = {}) {
|
||||
/**
|
||||
* Log in to `host` over SSH as `user` — for `ssh://<host>/` itself, or, with
|
||||
* `alias`, for `ssh://<alias>/` (one more account on the same host) — with
|
||||
* the key `key` only, or every key when it is null. Replaces our earlier rule
|
||||
* for that host or alias.
|
||||
*/
|
||||
async function setSshUser(account, { host, user, alias = null, key = null } = {}) {
|
||||
const h = String(host || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const u = String(user || "").trim();
|
||||
const a = alias == null || alias === "" ? null : String(alias).trim().toLowerCase();
|
||||
if (!HOST_RE.test(h)) {
|
||||
throw err("EBADHOST", "a host name like git-codecommit.us-east-1.amazonaws.com");
|
||||
}
|
||||
if (!SSH_USER_RE.test(u)) throw err("EBADSSHUSER", "a user name of letters, digits, . _ -");
|
||||
await removeSshUser(account, h);
|
||||
if (a !== null && !ALIAS_RE.test(a)) {
|
||||
throw err("EBADALIAS", "an account name of lowercase letters, digits and - (no dot)");
|
||||
}
|
||||
if (key != null && key !== "" && !currentKeys(account).some((k) => k.id === key)) {
|
||||
throw err("ENOKEY", "no such SSH key");
|
||||
}
|
||||
const from = a ?? h;
|
||||
await removeSshUser(account, from);
|
||||
const { git } = require("./worktree");
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--add",
|
||||
`url.${insteadOfBase(h, u)}.insteadOf`,
|
||||
`ssh://${h}/`,
|
||||
`ssh://${from}/`,
|
||||
]);
|
||||
if (key) await git(homeOf(account), ["config", "--global", `agenthub.${from}.sshKey`, key]);
|
||||
return listSshUsers(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* The key file a clone of `url` must use: the one its login names, when the
|
||||
* URL is `ssh://<host or alias>/…` with no user of its own (the only shape
|
||||
* our insteadOf rules rewrite). Null: offer every key.
|
||||
*/
|
||||
async function linkedKeyFile(account, url) {
|
||||
if (!url.startsWith("ssh://")) return null;
|
||||
let from;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.username) return null;
|
||||
from = u.host.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const row = (await listSshUsers(account)).find((r) => (r.alias ?? r.host) === from && r.key);
|
||||
return row ? (currentKeys(account).find((k) => k.id === row.key)?.file ?? null) : null;
|
||||
}
|
||||
|
||||
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
|
||||
|
||||
/**
|
||||
@@ -573,7 +746,7 @@ async function sshConfigFor(dir) {
|
||||
}
|
||||
// Our own terminal sshCommand (global) is not the repo's choice: it has no
|
||||
// BatchMode, so it would leave a dashboard fetch waiting on a prompt.
|
||||
if (own && own !== terminalSshCommand(account)) return [];
|
||||
if (own && !isOurTerminal(account, own)) return [];
|
||||
return [
|
||||
"-c",
|
||||
`core.sshCommand=ssh${identityArgs(keyFiles(account))} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
|
||||
@@ -683,18 +856,23 @@ async function cloneRepo({ url, parent, name, once = null }) {
|
||||
|
||||
if (remote.scheme === "ssh") {
|
||||
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
|
||||
const keys = keyFiles(account);
|
||||
// A login naming its key (another AWS account, a second GitHub user)
|
||||
// offers that key ONLY — another key could log in as the wrong account.
|
||||
const linked = await linkedKeyFile(account, url);
|
||||
const keys = linked ? [linked] : keyFiles(account);
|
||||
const base = keys.length ? `ssh${identityArgs(keys)} -o IdentitiesOnly=yes` : "ssh";
|
||||
// BatchMode: no terminal to answer a prompt; accept-new: first contact
|
||||
// records the host key, a CHANGED one still fails.
|
||||
pre.push("-c", `core.sshCommand=${base} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`);
|
||||
if (keys.length) {
|
||||
// Persisted by FILE name, both types: a key made later is offered too
|
||||
// (ssh skips a missing IdentityFile silently, where `-i` would warn).
|
||||
// Persisted by FILE name — both default types plus every named key:
|
||||
// a default key made later is offered too (ssh skips a missing
|
||||
// IdentityFile silently, where `-i` would warn). A linked key alone.
|
||||
const { sshDir } = paths(account);
|
||||
const all = Object.values(KEY_FILES)
|
||||
.map((f) => ` -o IdentityFile=${shq(path.join(sshDir, f))}`)
|
||||
.join("");
|
||||
const files = linked
|
||||
? [linked]
|
||||
: [...new Set([...Object.values(KEY_FILES).map((f) => path.join(sshDir, f)), ...keys])];
|
||||
const all = files.map((f) => ` -o IdentityFile=${shq(f)}`).join("");
|
||||
persist.push("-c", `core.sshCommand=ssh${all} -o IdentitiesOnly=yes`);
|
||||
}
|
||||
} else if (account.kind === "member") {
|
||||
@@ -798,6 +976,7 @@ module.exports = {
|
||||
defaultName,
|
||||
cloneRepo,
|
||||
getAuthor,
|
||||
defaultEmail,
|
||||
setAuthor,
|
||||
sshConfigFor,
|
||||
memberHttpsConfig,
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* @file host-stats.js
|
||||
* @description The Administration page's Server tab (`GET /api/host`, staff
|
||||
* only): the machine's hardware and load, and how much of its CPU and memory
|
||||
* each AgentHub user is using right now. Read-only — nothing here signals,
|
||||
* renices or kills a process.
|
||||
*
|
||||
* Hardware comes from `os` plus `/proc` (meminfo, stat, mounts) and `statfs`
|
||||
* per real mounted file system (HDD/SSD from `/sys/block/<dev>/queue/rotational`);
|
||||
* a GPU only when `nvidia-smi` answers within 3 s. Usage is two `/proc`
|
||||
* samples `SAMPLE_MS` apart: a process's CPU % is its utime+stime delta over
|
||||
* the whole machine's jiffies delta (100 % = every core busy), its memory its
|
||||
* RSS. A process is attributed, in order, to: the AgentHub user of its uid
|
||||
* (`ah-<name>` under S3, `memberNameForUid`); for a process of the host
|
||||
* account, the OWNER of the workspace named by its inherited
|
||||
* `AGENTHUB_LANE_ID` (every pane gets it through tmux `-e`, so an agent's
|
||||
* children carry it too); else `host` (the dashboard and everything else the
|
||||
* host account runs) or `system` (root and every other account). Only the
|
||||
* kernel's `comm` name is reported for a process — never its argv or env,
|
||||
* which can hold a prompt or a key. Every read is async, and one sample is
|
||||
* shared by concurrent callers and cached for `FRESH_MS`. Linux only: other
|
||||
* platforms get the `os` facts with `usage: null`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFile } = require("node:child_process");
|
||||
|
||||
const SAMPLE_MS = 1000;
|
||||
const FRESH_MS = 3000;
|
||||
const TOP_PER_GROUP = 5;
|
||||
// File systems that hold data; pseudo and loop mounts are left out.
|
||||
const REAL_FS = new Set([
|
||||
"ext2",
|
||||
"ext3",
|
||||
"ext4",
|
||||
"xfs",
|
||||
"btrfs",
|
||||
"zfs",
|
||||
"f2fs",
|
||||
"vfat",
|
||||
"exfat",
|
||||
"ntfs",
|
||||
"ntfs3",
|
||||
"fuseblk",
|
||||
"nfs",
|
||||
"nfs4",
|
||||
"cifs",
|
||||
]);
|
||||
|
||||
const readText = (file) => fsp.readFile(file, "utf8").catch(() => null);
|
||||
|
||||
/** `/proc/meminfo` in bytes: total, available, swap. */
|
||||
async function memory() {
|
||||
const text = await readText("/proc/meminfo");
|
||||
const kb = (key) => {
|
||||
const m = text && new RegExp(`^${key}:\\s+(\\d+)`, "m").exec(text);
|
||||
return m ? Number(m[1]) * 1024 : null;
|
||||
};
|
||||
const total = kb("MemTotal") ?? os.totalmem();
|
||||
const available = kb("MemAvailable") ?? os.freemem();
|
||||
return {
|
||||
total,
|
||||
available,
|
||||
used: total - available,
|
||||
swapTotal: kb("SwapTotal") ?? 0,
|
||||
swapUsed: (kb("SwapTotal") ?? 0) - (kb("SwapFree") ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** The machine's jiffies from `/proc/stat`'s `cpu` line: `{ total, idle }`. */
|
||||
async function cpuTimes() {
|
||||
const text = await readText("/proc/stat");
|
||||
const line = text && text.split("\n").find((l) => l.startsWith("cpu "));
|
||||
if (!line) return null;
|
||||
const n = line.trim().split(/\s+/).slice(1).map(Number);
|
||||
return { total: n.reduce((a, b) => a + b, 0), idle: n[3] + (n[4] || 0) };
|
||||
}
|
||||
|
||||
/** `HDD`/`SSD` for a `/dev/...` device, or null when /sys cannot tell. */
|
||||
function diskKind(device) {
|
||||
if (!device.startsWith("/dev/")) return null;
|
||||
let name = path.basename(device);
|
||||
try {
|
||||
name = path.basename(fs.realpathSync(device));
|
||||
} catch {
|
||||
/* a mapper name that does not resolve: try it as is */
|
||||
}
|
||||
// A partition's queue lives on its parent: sda1 → sda, nvme0n1p2 → nvme0n1.
|
||||
const candidates = [name, name.replace(/p?\d+$/, "")];
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const v = fs.readFileSync(`/sys/block/${c}/queue/rotational`, "utf8").trim();
|
||||
return v === "1" ? "HDD" : "SSD";
|
||||
} catch {
|
||||
/* next */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every real mounted file system, one row per device. */
|
||||
async function disks() {
|
||||
const text = await readText("/proc/mounts");
|
||||
if (!text) return [];
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const [device, rawMount, type] = line.split(" ");
|
||||
if (!device || !REAL_FS.has(type) || seen.has(device)) continue;
|
||||
if (device.startsWith("/dev/loop")) continue;
|
||||
const mount = rawMount.replace(/\\040/g, " ");
|
||||
if (mount.startsWith("/snap/") || mount.startsWith("/boot/efi")) continue;
|
||||
seen.add(device);
|
||||
try {
|
||||
const st = await fsp.statfs(mount);
|
||||
const total = st.blocks * st.bsize;
|
||||
if (!total) continue;
|
||||
const free = st.bavail * st.bsize;
|
||||
out.push({
|
||||
device,
|
||||
mount,
|
||||
type,
|
||||
kind: diskKind(device),
|
||||
total,
|
||||
used: total - st.bfree * st.bsize,
|
||||
free,
|
||||
});
|
||||
} catch {
|
||||
/* unreadable mount: skip */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The mount (from `list`) holding `p`: the longest mount point that prefixes it. */
|
||||
function mountOf(list, p) {
|
||||
if (!p) return null;
|
||||
let best = null;
|
||||
for (const d of list) {
|
||||
const m = d.mount === "/" ? "/" : `${d.mount}/`;
|
||||
if ((p === d.mount || p.startsWith(m)) && (!best || d.mount.length > best.mount.length))
|
||||
best = d;
|
||||
}
|
||||
return best ? best.mount : null;
|
||||
}
|
||||
|
||||
/** `nvidia-smi` GPUs, or [] when there is none (or it does not answer in 3 s). */
|
||||
function gpus() {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
"nvidia-smi",
|
||||
[
|
||||
"--query-gpu=name,utilization.gpu,memory.used,memory.total",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
{ timeout: 3000, windowsHide: true },
|
||||
(err, stdout) => {
|
||||
if (err) return resolve([]);
|
||||
resolve(
|
||||
String(stdout)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => {
|
||||
const [name, util, used, total] = l.split(",").map((s) => s.trim());
|
||||
return {
|
||||
name,
|
||||
utilization: Number(util) || 0,
|
||||
memoryUsed: (Number(used) || 0) * 1024 * 1024,
|
||||
memoryTotal: (Number(total) || 0) * 1024 * 1024,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function distro() {
|
||||
try {
|
||||
const m = /^PRETTY_NAME="?([^"\n]*)"?/m.exec(fs.readFileSync("/etc/os-release", "utf8"));
|
||||
return m ? m[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const PAGE = 4096;
|
||||
|
||||
/**
|
||||
* One pass over `/proc`: `Map<pid, { uid, comm, ticks, rss }>`. `stat`'s comm
|
||||
* is in parentheses and may itself hold spaces or `)`, so the fields are read
|
||||
* after the LAST `)`.
|
||||
*/
|
||||
async function processes() {
|
||||
const out = new Map();
|
||||
let names;
|
||||
try {
|
||||
names = await fsp.readdir("/proc");
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
await Promise.all(
|
||||
names
|
||||
.filter((n) => /^\d+$/.test(n))
|
||||
.map(async (pid) => {
|
||||
try {
|
||||
const [stat, st] = await Promise.all([
|
||||
fsp.readFile(`/proc/${pid}/stat`, "utf8"),
|
||||
fsp.stat(`/proc/${pid}`),
|
||||
]);
|
||||
const close = stat.lastIndexOf(")");
|
||||
const comm = stat.slice(stat.indexOf("(") + 1, close);
|
||||
const f = stat.slice(close + 2).split(" ");
|
||||
// After comm: state(0) ppid(1) … utime(11) stime(12) … rss(21).
|
||||
out.set(Number(pid), {
|
||||
uid: st.uid,
|
||||
comm,
|
||||
ticks: Number(f[11]) + Number(f[12]),
|
||||
rss: Number(f[21]) * PAGE,
|
||||
});
|
||||
} catch {
|
||||
/* exited meanwhile */
|
||||
}
|
||||
})
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The `AGENTHUB_LANE_ID` a host process inherited, or null. */
|
||||
async function laneIdOf(pid) {
|
||||
try {
|
||||
const env = await fsp.readFile(`/proc/${pid}/environ`, "utf8");
|
||||
const m = /(?:^|\0)AGENTHUB_LANE_ID=(\d+)(?:\0|$)/.exec(env);
|
||||
return m ? Number(m[1]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Defaults wired to the real modules; a test swaps them (`__setDeps`). */
|
||||
let deps = {
|
||||
memberNameForUid: (uid) => require("./run-as").memberNameForUid(uid),
|
||||
laneOwner: (laneId) => {
|
||||
const { db } = require("../db");
|
||||
const row = db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(laneId);
|
||||
return row ? row.owner_id : null;
|
||||
},
|
||||
users: () => {
|
||||
try {
|
||||
return require("./auth").listUsers();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
homesRoot: () => {
|
||||
try {
|
||||
const linux = require("./linux-accounts");
|
||||
if (linux.s3Enabled()) return linux.serverConf().usersRoot;
|
||||
} catch {
|
||||
/* no S3 */
|
||||
}
|
||||
return process.env.AGENTHUB_USERS_ROOT || os.homedir();
|
||||
},
|
||||
wait: (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||
};
|
||||
|
||||
/** Per-user CPU and memory from two samples. */
|
||||
async function usage() {
|
||||
const t0 = await cpuTimes();
|
||||
const p0 = await processes();
|
||||
await deps.wait(SAMPLE_MS);
|
||||
const t1 = await cpuTimes();
|
||||
const p1 = await processes();
|
||||
const mem = await memory();
|
||||
if (!t0 || !t1) return null;
|
||||
const totalDelta = Math.max(1, t1.total - t0.total);
|
||||
const hostUid = typeof process.getuid === "function" ? process.getuid() : -1;
|
||||
|
||||
const users = deps.users();
|
||||
const byUsername = new Map(users.map((u) => [u.username, u]));
|
||||
const byId = new Map(users.map((u) => [u.id, u]));
|
||||
const ownerCache = new Map();
|
||||
const groups = new Map();
|
||||
const group = (key, extra) => {
|
||||
if (!groups.has(key))
|
||||
groups.set(key, { key, cpu: 0, memory: 0, processes: 0, top: [], ...extra });
|
||||
return groups.get(key);
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
[...p1].map(async ([pid, p]) => {
|
||||
const before = p0.get(pid);
|
||||
// A process born between the samples: all its ticks are in the window.
|
||||
const ticks = Math.max(0, p.ticks - (before ? before.ticks : 0));
|
||||
let g = null;
|
||||
const member = deps.memberNameForUid(p.uid);
|
||||
if (member && byUsername.has(member)) {
|
||||
const u = byUsername.get(member);
|
||||
g = group(`user:${u.id}`, { kind: "user", userId: u.id, username: u.username });
|
||||
} else if (p.uid === hostUid) {
|
||||
const laneId = await laneIdOf(pid);
|
||||
let ownerId = null;
|
||||
if (laneId != null) {
|
||||
if (!ownerCache.has(laneId)) {
|
||||
let o = null;
|
||||
try {
|
||||
o = deps.laneOwner(laneId);
|
||||
} catch {
|
||||
o = null;
|
||||
}
|
||||
ownerCache.set(laneId, o);
|
||||
}
|
||||
ownerId = ownerCache.get(laneId);
|
||||
}
|
||||
const u = ownerId != null ? byId.get(ownerId) : null;
|
||||
g = u
|
||||
? group(`user:${u.id}`, { kind: "user", userId: u.id, username: u.username })
|
||||
: group("host", { kind: "host" });
|
||||
} else {
|
||||
g = group("system", { kind: "system" });
|
||||
}
|
||||
const cpu = (ticks / totalDelta) * 100;
|
||||
g.cpu += cpu;
|
||||
g.memory += p.rss;
|
||||
g.processes += 1;
|
||||
g.top.push({ pid, name: p.comm, cpu, memory: p.rss });
|
||||
})
|
||||
);
|
||||
|
||||
const list = [...groups.values()].map((g) => {
|
||||
g.top.sort((a, b) => b.cpu - a.cpu || b.memory - a.memory);
|
||||
g.top = g.top.slice(0, TOP_PER_GROUP).map((t) => ({ ...t, cpu: round(t.cpu) }));
|
||||
return { ...g, cpu: round(g.cpu), memoryPercent: round((g.memory / mem.total) * 100) };
|
||||
});
|
||||
// Every user appears, idle ones with zeros, so the table is the whole roster.
|
||||
for (const u of users) {
|
||||
if (!groups.has(`user:${u.id}`))
|
||||
list.push({
|
||||
key: `user:${u.id}`,
|
||||
kind: "user",
|
||||
userId: u.id,
|
||||
username: u.username,
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
memoryPercent: 0,
|
||||
processes: 0,
|
||||
top: [],
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => b.cpu - a.cpu || b.memory - a.memory);
|
||||
return {
|
||||
cpu: round(((totalDelta - (t1.idle - t0.idle)) / totalDelta) * 100),
|
||||
sampleMs: SAMPLE_MS,
|
||||
groups: list,
|
||||
};
|
||||
}
|
||||
|
||||
const round = (n) => Math.round(n * 10) / 10;
|
||||
|
||||
async function collect() {
|
||||
const linux = process.platform === "linux";
|
||||
const cpus = os.cpus();
|
||||
const [mem, diskList, gpuList, use] = await Promise.all([
|
||||
memory(),
|
||||
linux ? disks() : [],
|
||||
linux ? gpus() : [],
|
||||
linux ? usage() : null,
|
||||
]);
|
||||
return {
|
||||
at: new Date().toISOString(),
|
||||
hostname: os.hostname(),
|
||||
os: {
|
||||
platform: process.platform,
|
||||
release: os.release(),
|
||||
distro: linux ? distro() : null,
|
||||
arch: os.arch(),
|
||||
},
|
||||
uptime: os.uptime(),
|
||||
cpu: { model: cpus[0]?.model?.trim() || null, cores: cpus.length, load: os.loadavg() },
|
||||
memory: mem,
|
||||
disks: diskList,
|
||||
homesMount: mountOf(diskList, deps.homesRoot()),
|
||||
gpus: gpuList,
|
||||
usage: use,
|
||||
};
|
||||
}
|
||||
|
||||
let cache = null; // { at, value }
|
||||
let inflight = null;
|
||||
|
||||
/** The current snapshot: cached for `FRESH_MS`, one sample shared by every caller. */
|
||||
async function hostStats() {
|
||||
if (cache && Date.now() - cache.at < FRESH_MS) return cache.value;
|
||||
if (!inflight) {
|
||||
inflight = collect()
|
||||
.then((value) => {
|
||||
cache = { at: Date.now(), value };
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
}
|
||||
return inflight;
|
||||
}
|
||||
|
||||
function __setDeps(over) {
|
||||
deps = { ...deps, ...over };
|
||||
cache = null;
|
||||
}
|
||||
|
||||
module.exports = { hostStats, mountOf, __setDeps };
|
||||
@@ -6,7 +6,7 @@
|
||||
* workspaces in.
|
||||
*
|
||||
* S3 is ON when server mode is on AND `/etc/agenthub/server.conf` exists
|
||||
* (written by `sudo agenthub server install-system`, root-owned). Without it
|
||||
* (written by `agenthub server install-system`, root-owned). Without it
|
||||
* everything here is a no-op and server mode behaves as in S2.
|
||||
*
|
||||
* `provision` is the dashboard's only way to create, disable or enable a Linux
|
||||
@@ -158,7 +158,7 @@ function provision(action, username) {
|
||||
// An installed helper older than this build (root's copy is refreshed
|
||||
// only by install-system) does not know the action yet.
|
||||
const hint = /unknown action/.test(why)
|
||||
? " — the server helper is older than this AgentHub: run `sudo agenthub server install-system` once"
|
||||
? " — the server helper is older than this AgentHub: run `agenthub server install-system` once"
|
||||
: "";
|
||||
throw Object.assign(new Error(`could not ${action} the Linux account: ${why}${hint}`), {
|
||||
code: "EPROVISION",
|
||||
|
||||
@@ -276,17 +276,23 @@ const paths = {
|
||||
"/api/me/git/ssh-key": {
|
||||
post: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Make (or with `replace`, replace) the SSH key of `type`; the other type is kept",
|
||||
summary:
|
||||
"Make (or with `replace`, replace) the default SSH key of `type`, or with `name` one more beside it",
|
||||
operationId: "generateSshKey",
|
||||
requestBody: json({
|
||||
replace: { type: "boolean" },
|
||||
type: { type: "string", enum: ["ed25519", "rsa"] },
|
||||
name: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,31}$" },
|
||||
}),
|
||||
responses: { 201: { description: "The identity." } },
|
||||
responses: {
|
||||
201: { description: "The identity." },
|
||||
400: { description: "EBADKEYNAME" },
|
||||
409: { description: "EEXISTS (without replace, or the name is taken) / ETOOMANYKEYS" },
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Delete the SSH key of ?type=ed25519|rsa, or every key",
|
||||
summary: "Delete one SSH key (?id=, or the default of ?type=ed25519|rsa), or every key",
|
||||
operationId: "deleteSshKey",
|
||||
parameters: [
|
||||
{
|
||||
@@ -295,6 +301,7 @@ const paths = {
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["ed25519", "rsa"] },
|
||||
},
|
||||
{ name: "id", in: "query", required: false, schema: { type: "string" } },
|
||||
],
|
||||
responses: { 200: { description: "The identity." } },
|
||||
},
|
||||
@@ -311,6 +318,36 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/git/ssh-user": {
|
||||
put: {
|
||||
tags: ["Git identity"],
|
||||
summary:
|
||||
"An SSH login: url.<ssh://user@host/>.insteadOf ssh://<alias or host>/, optionally tied to one key",
|
||||
operationId: "setSshUser",
|
||||
requestBody: json(
|
||||
{
|
||||
host: { type: "string" },
|
||||
user: { type: "string" },
|
||||
alias: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,62}$" },
|
||||
key: { type: "string" },
|
||||
},
|
||||
["host", "user"]
|
||||
),
|
||||
responses: {
|
||||
200: { description: "The identity; `sshUsers: [{host, user, alias, key}]`." },
|
||||
400: { description: "EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/git/ssh-user/{from}": {
|
||||
delete: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Drop the SSH login of a host or alias (and its key link)",
|
||||
operationId: "deleteSshUser",
|
||||
parameters: [{ name: "from", in: "path", required: true, schema: { type: "string" } }],
|
||||
responses: { 200: { description: "The identity." }, 404: { description: "ENOTFOUND" } },
|
||||
},
|
||||
},
|
||||
"/api/me/git/https": {
|
||||
post: {
|
||||
tags: ["Git identity"],
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
/**
|
||||
* @file HTTP surface for a user's OWN git identity (`server/lib/git-identity.js`),
|
||||
* mounted at `/api/me/git`: the SSH keys a clone uses — ed25519 and/or rsa (make, replace, delete —
|
||||
* only the PUBLIC key is ever returned) and the HTTPS logins (save, delete —
|
||||
* `hasToken` only, never a password), whether the account's own terminal
|
||||
* git uses that key (`core.sshCommand`), the SSH login name per host
|
||||
* (`url.<…>.insteadOf` — AWS CodeCommit's SSH key ID), and the commit author (`user.name` /
|
||||
* mounted at `/api/me/git`: the SSH keys a clone uses — a default ed25519
|
||||
* and/or rsa one plus named ones (`name`, deleted by `?id=`) for more
|
||||
* accounts (make, replace, delete — only the PUBLIC key is ever returned) and
|
||||
* the HTTPS logins (save, delete — `hasToken` only, never a password),
|
||||
* whether the account's own terminal git uses the keys (`core.sshCommand`),
|
||||
* the SSH logins per host or alias (`url.<…>.insteadOf` — AWS CodeCommit's
|
||||
* SSH key ID — each optionally tied to one key), and the commit author (`user.name` /
|
||||
* `user.email` of the account's global git config — a member's commits fail
|
||||
* without one). The account is the signed-in user's
|
||||
* without one; a new SSH key is labelled with that email, else
|
||||
* `<username>@smartgift.vn`). The account is the signed-in user's
|
||||
* own Linux account under S3, else the host's, which only an admin (or local
|
||||
* mode) may change (`EHOSTIDENTITY`). Nothing here takes a path from the
|
||||
* request, and every write passes the same-origin guard.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const express = require("express");
|
||||
|
||||
const identity = require("../lib/git-identity");
|
||||
@@ -31,6 +35,10 @@ const STATUS = {
|
||||
EBADKEYTYPE: 400,
|
||||
EFOREIGNSSH: 409,
|
||||
EBADSSHUSER: 400,
|
||||
EBADKEYNAME: 400,
|
||||
ETOOMANYKEYS: 409,
|
||||
EBADALIAS: 400,
|
||||
ENOKEY: 400,
|
||||
};
|
||||
|
||||
function fail(res, e) {
|
||||
@@ -39,7 +47,7 @@ function fail(res, e) {
|
||||
.json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
|
||||
async function view(account) {
|
||||
async function view(account, username) {
|
||||
const keys = identity.sshKeysInfo(account);
|
||||
const terminal = keys.length ? await identity.terminalSshState(account) : undefined;
|
||||
return {
|
||||
@@ -52,9 +60,14 @@ async function view(account) {
|
||||
sshUsers: await identity.listSshUsers(account),
|
||||
https: identity.listHttps(account),
|
||||
author: await identity.getAuthor(account),
|
||||
// What the author email form suggests while none is set.
|
||||
defaultEmail: identity.defaultEmail(username),
|
||||
};
|
||||
}
|
||||
|
||||
/** The signed-in user's name, or the OS user's in local mode. */
|
||||
const usernameOf = (req) => req.user?.username || os.userInfo().username;
|
||||
|
||||
/** The request's account; a write to the host's identity needs an admin. */
|
||||
function accountOf(req, { write = false } = {}) {
|
||||
const account = identity.accountForRequest(req);
|
||||
@@ -68,7 +81,7 @@ function accountOf(req, { write = false } = {}) {
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
res.json(await view(accountOf(req)));
|
||||
res.json(await view(accountOf(req), usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
@@ -80,9 +93,12 @@ router.post("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
await identity.generateSshKey(account, {
|
||||
replace: req.body?.replace === true,
|
||||
type: req.body?.type === "rsa" ? "rsa" : "ed25519",
|
||||
comment: req.user?.username ? `${req.user.username}@agenthub` : "agenthub",
|
||||
// A named key sits beside the default one of its type.
|
||||
name: typeof req.body?.name === "string" ? req.body.name : null,
|
||||
// The key is labelled with the commit author's email, else the default one.
|
||||
comment: (await identity.getAuthor(account)).email || identity.defaultEmail(usernameOf(req)),
|
||||
});
|
||||
res.status(201).json(await view(account));
|
||||
res.status(201).json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
@@ -92,10 +108,11 @@ router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
const type = req.query.type === undefined ? undefined : String(req.query.type);
|
||||
if (!(await identity.removeSshKey(account, type))) {
|
||||
const id = req.query.id === undefined ? undefined : String(req.query.id);
|
||||
if (!(await identity.removeSshKey(account, { type, id }))) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
|
||||
}
|
||||
res.json(await view(account));
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
@@ -112,21 +129,23 @@ router.put("/ssh-terminal", sameOriginGuard, async (req, res) => {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
|
||||
}
|
||||
await identity.setTerminalSsh(account, req.body?.on === true);
|
||||
res.json(await view(account));
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The SSH user name for one host (`{host, user}`) — e.g. AWS CodeCommit's SSH
|
||||
* key ID, so a plain `ssh://git-codecommit…/` URL logs in. DELETE drops it.
|
||||
* An SSH login (`{host, user, alias?, key?}`) — e.g. AWS CodeCommit's SSH key
|
||||
* ID, so a plain `ssh://git-codecommit…/` URL logs in; with `alias`, one more
|
||||
* account on that host (`ssh://<alias>/…`); with `key`, the only key it uses.
|
||||
* DELETE `/ssh-user/<host or alias>` drops it.
|
||||
*/
|
||||
router.put("/ssh-user", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
await identity.setSshUser(account, req.body || {});
|
||||
res.json(await view(account));
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
@@ -138,7 +157,7 @@ router.delete("/ssh-user/:host", sameOriginGuard, async (req, res) => {
|
||||
if (!(await identity.removeSshUser(account, req.params.host))) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such SSH user" } });
|
||||
}
|
||||
res.json(await view(account));
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
@@ -149,7 +168,7 @@ router.post("/https", sameOriginGuard, async (req, res) => {
|
||||
const account = accountOf(req, { write: true });
|
||||
const { host, username, password } = req.body || {};
|
||||
identity.saveHttps(account, { protocol: "https", host, username, password });
|
||||
res.json(await view(account));
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
@@ -161,7 +180,7 @@ router.delete("/https/:host", sameOriginGuard, async (req, res) => {
|
||||
if (!identity.removeHttps(account, "https", req.params.host)) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such login" } });
|
||||
}
|
||||
res.json(await view(account));
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
@@ -172,7 +191,7 @@ router.put("/author", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
await identity.setAuthor(account, req.body || {});
|
||||
res.json(await view(account));
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
|
||||
+69
-11
@@ -4,13 +4,18 @@
|
||||
* `inHome` so the dot entries at the top of a home stay out of reach).
|
||||
*
|
||||
* GET /api/homes the homes the caller may open
|
||||
* GET /api/homes/trash every Trash the caller may open, merged
|
||||
* GET /api/homes/:who/fs/list|trash|zip|download
|
||||
* POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
|
||||
*
|
||||
* `:who` is `me`, or — for an admin — another user's name: read-only
|
||||
* (`403 EREADONLY` on every write) and written to the audit log. Writes are
|
||||
* same-origin; a copy may come from a workspace (`fromLane`) the caller can
|
||||
* see, of the same Linux account.
|
||||
* (`403 EREADONLY` on every write) and written to the audit log. The one
|
||||
* exception is that user's TRASH: an admin lists, restores and purges it
|
||||
* (the recycle-bin management in /files), each write audited with the item
|
||||
* id. A home's Trash is its USER's whole recycle bin (`fx.listUserTrash`:
|
||||
* their home account's trash plus their host trash), each item tagged with
|
||||
* the `store` a restore/purge names. Writes are same-origin; a copy may come
|
||||
* from a workspace (`fromLane`) the caller can see, of the same Linux account.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -57,12 +62,12 @@ const paths = (body) =>
|
||||
* Resolve `:who`, refuse a write to someone else's home, audit an admin's
|
||||
* read of it, then run `fn(home)` inside the home rule.
|
||||
*/
|
||||
function handle(fn, { write = false } = {}) {
|
||||
function handle(fn, { write = false, trash = false } = {}) {
|
||||
return async (req, res) => {
|
||||
let home;
|
||||
try {
|
||||
home = homes.resolveHome(req, req.params.who);
|
||||
if (write && home.readOnly) {
|
||||
if (write && !trash && home.readOnly) {
|
||||
throw Object.assign(new Error("another user's home is read-only"), { code: "EREADONLY" });
|
||||
}
|
||||
if (home.readOnly && req.user) {
|
||||
@@ -70,7 +75,7 @@ function handle(fn, { write = false } = {}) {
|
||||
req.user.id,
|
||||
home.ownerId,
|
||||
req.method,
|
||||
`home ${req.params.who}: ${req.path} ${String(req.query.path ?? "")}`
|
||||
`home ${req.params.who}: ${req.path} ${String(req.query.path ?? req.body?.id ?? "")}`
|
||||
);
|
||||
}
|
||||
await fx.inHome(() => fn(req, res, home));
|
||||
@@ -81,11 +86,51 @@ function handle(fn, { write = false } = {}) {
|
||||
};
|
||||
}
|
||||
const trashFor = (req, home) => (rel) => fx.trashOne(home.dir, null, rel, userKey(req));
|
||||
/** Whose trash a home's Trash view shows: the home OWNER's (an admin looking
|
||||
* at another user's home manages THEIR recycle bin, not the admin's own). */
|
||||
const trashKey = (req, home) =>
|
||||
home.readOnly && home.ownerId != null ? String(home.ownerId) : userKey(req);
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
res.json({ homes: homes.listHomes(req) });
|
||||
});
|
||||
|
||||
/**
|
||||
* Every recycle bin the caller may open in one list — an admin's is every
|
||||
* user's — each item tagged with its home (`home`, the `:who` a restore or
|
||||
* purge goes to) and that user's name (`owner`). A home whose trash cannot
|
||||
* be read is reported in `errors`, never silently left out. Each other
|
||||
* user's trash read is audited like opening their home.
|
||||
*/
|
||||
router.get("/trash", async (req, res) => {
|
||||
const items = [];
|
||||
const errors = [];
|
||||
let keepDays;
|
||||
for (const h of homes.listHomes(req)) {
|
||||
try {
|
||||
const home = homes.resolveHome(req, h.id);
|
||||
if (home.readOnly && req.user) {
|
||||
require("../lib/audit").recordHomeRead(
|
||||
req.user.id,
|
||||
home.ownerId,
|
||||
"GET",
|
||||
`home ${h.id}: /trash (all trashes)`
|
||||
);
|
||||
}
|
||||
const out = await fx.listUserTrash(home.dir, trashKey(req, home));
|
||||
keepDays = out.keepDays;
|
||||
for (const i of out.items) {
|
||||
if (i.laneId != null && !scope.canSeeLane(req, i.laneId)) continue;
|
||||
items.push({ ...i, home: h.id, owner: h.label });
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({ home: h.id, owner: h.label, message: err.message });
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
|
||||
res.json({ items, errors, keepDays });
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/:who/fs/list",
|
||||
handle(async (req, res, home) => {
|
||||
@@ -136,13 +181,15 @@ router.get(
|
||||
router.get(
|
||||
"/:who/fs/trash",
|
||||
handle(async (req, res, home) => {
|
||||
const out = await fx.listTrash(home.dir, userKey(req));
|
||||
const out = await fx.listUserTrash(home.dir, trashKey(req, home));
|
||||
out.items = out.items.filter((i) => i.laneId == null || scope.canSeeLane(req, i.laneId));
|
||||
res.json(out);
|
||||
})
|
||||
);
|
||||
|
||||
const W = { write: true };
|
||||
// Restore/purge: writes, but allowed in another user's home for an admin.
|
||||
const TRASH_W = { write: true, trash: true };
|
||||
router.post(
|
||||
"/:who/fs/mkdir",
|
||||
sameOriginGuard,
|
||||
@@ -223,15 +270,26 @@ router.post(
|
||||
const l = lanesLib.getLane(Number(laneId));
|
||||
return l && scope.canSeeLane(req, l.id) ? l.cwd : null;
|
||||
};
|
||||
res.json(await fx.restore(home.dir, req.body?.id, userKey(req), resolveLane));
|
||||
}, W)
|
||||
res.json(
|
||||
await fx.restoreUserTrash(
|
||||
home.dir,
|
||||
trashKey(req, home),
|
||||
req.body?.store,
|
||||
req.body?.id,
|
||||
resolveLane
|
||||
)
|
||||
);
|
||||
}, TRASH_W)
|
||||
);
|
||||
router.post(
|
||||
"/:who/fs/trash/purge",
|
||||
sameOriginGuard,
|
||||
handle(
|
||||
async (req, res, home) => res.json(await fx.purgeTrash(home.dir, userKey(req), req.body?.id)),
|
||||
W
|
||||
async (req, res, home) =>
|
||||
res.json(
|
||||
await fx.purgeUserTrash(home.dir, trashKey(req, home), req.body?.store, req.body?.id)
|
||||
),
|
||||
TRASH_W
|
||||
)
|
||||
);
|
||||
router.post(
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @file host.js
|
||||
* @description `GET /api/host` — the Administration page's Server tab: the
|
||||
* machine's hardware, load and disks, and each AgentHub user's share of its
|
||||
* CPU and memory right now (server/lib/host-stats.js). Read-only; staff only
|
||||
* in server mode (`/host` is in STAFF_ONLY_PREFIXES, server/lib/auth.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { hostStats } = require("../lib/host-stats");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
try {
|
||||
res.json(await hostStats());
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: { code: "EHOST", message: e.message } });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user