chore: v2.8.71 — remote clients (company hub), artifacts shared with chosen people, folders and viewable copies, fixes
Remote clients (docs/plans/remote-clients/README.md, B1–B3 for the Claude pool): - server/hub/ holds the hub-only code (devices, remote sessions, upload ingest, LAN gateway), required only in server mode; hub-boundary.test.js keeps local code from importing it. - A local install enrolls with `agenthub remote login` or Settings → AI tools → Company hub; the "Company account" AI Proxy (reserved name agenthub.company) sends a session through the hub's gateway, which adds a pool account's token — no credential reaches the machine. Only those sessions are uploaded, hourly and at their end; secret-shaped strings are redacted on the client. - Gateway refuses: unknown device (401), a message without a session id, an unregistered session under AGENTHUB_REMOTE_GATE=block, a user over quota (EQUOTA), a purged session (410). Device tokens 90 days, conversations 30. - New alert rule type remote_unmonitored: a company-account session its device never registered; a late registration clears the flag instead. - The hub imports a session once per upload round (chunks carry `more`), not once per chunk. Artifacts: - Share an artifact with chosen people (artifact_shares, PATCH sharedWith, GET /api/artifacts/people) besides everyone; a "Shared with you" tab. Only the owner or an admin sees or edits who it is shared with; audited. - Folders: each account's own arrangement (agenthub.artifacts.folders pref). - A claude.ai page keeps a copy viewable without the publishing account: its HTML file (and supporting files) snapshotted at publish, or the copy Claude saves for a Doc / Slides / Design in artifacts-export/ (a synchronous PostToolUse hint hook, scripts/artifact-export-hint.js, admin switch), attached to the page at Stop. Viewed in HtmlFrame's sandbox, Markdown, or pdf.js (pdfjs-dist 6.4.299, legacy build, loaded only for a PDF). Fixes: - tmux: a cached /proc/<pid>/exe of an exited server falls back to PATH's tmux (stop the last pane, start again within 10 s failed with ENOENT). - shelves: a shelf restores beside changes in other files. - chat: a finished reply ends the turn even with no hook at all. - Settings → AI tools: profile list display tweaks.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @file RemoteDevices.tsx
|
||||
* @description The hub's view of remote clients (server mode, server/hub/):
|
||||
* a user's enrolled machines with Revoke (the viewer's own password again —
|
||||
* the server checks it and writes the audit entry), and their Company-account
|
||||
* sessions grouped device › folder with what the gateway counted. Shown in
|
||||
* Settings for the signed-in user's own machines and on the Users page's user
|
||||
* detail for an admin/analyst (`userId`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Laptop, ShieldAlert } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, type RemoteDevice, type RemoteSessionRow } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
const fmt = (n: number) =>
|
||||
n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
|
||||
|
||||
export function RemoteDevices({ userId, canRevoke }: { userId?: number; canRevoke: boolean }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [devices, setDevices] = useState<RemoteDevice[] | null>(null);
|
||||
const [sessions, setSessions] = useState<RemoteSessionRow[]>([]);
|
||||
const [revoking, setRevoking] = useState<RemoteDevice | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.remoteHub
|
||||
.devices(userId)
|
||||
.then((r) => setDevices(r.devices))
|
||||
.catch(() => setDevices([]));
|
||||
api.remoteHub
|
||||
.sessions(userId)
|
||||
.then((r) => setSessions(r.sessions))
|
||||
.catch(() => setSessions([]));
|
||||
}, [userId]);
|
||||
useEffect(load, [load]);
|
||||
|
||||
// device › folder › sessions, the newest first (the API's order).
|
||||
const groups = useMemo(() => {
|
||||
const byDevice = new Map<string, Map<string, RemoteSessionRow[]>>();
|
||||
for (const s of sessions) {
|
||||
const d = s.deviceName;
|
||||
if (!byDevice.has(d)) byDevice.set(d, new Map());
|
||||
const folders = byDevice.get(d)!;
|
||||
const f = s.cwd || "—";
|
||||
if (!folders.has(f)) folders.set(f, []);
|
||||
folders.get(f)!.push(s);
|
||||
}
|
||||
return [...byDevice.entries()];
|
||||
}, [sessions]);
|
||||
|
||||
if (devices === null) return null;
|
||||
return (
|
||||
<div className="space-y-4" data-testid="remote-devices">
|
||||
{devices.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("companyHub.noDevices")}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{devices.map((d) => (
|
||||
<li key={d.id} className="flex flex-wrap items-center gap-2 px-3 py-2 text-xs">
|
||||
<Laptop className="h-3.5 w-3.5 text-fg-muted" />
|
||||
<span className="font-medium text-fg-primary">{d.name}</span>
|
||||
<span className="text-fg-muted">
|
||||
{d.revokedAt
|
||||
? t("companyHub.revokedAt", { time: formatDateTime(d.revokedAt) })
|
||||
: d.active
|
||||
? t("companyHub.validUntil", { time: formatDateTime(d.expiresAt) })
|
||||
: t("companyHub.expired")}
|
||||
</span>
|
||||
{d.lastSeenAt && (
|
||||
<span className="text-fg-muted">
|
||||
·{" "}
|
||||
{t("companyHub.lastSeen", {
|
||||
time: formatDateTime(d.lastSeenAt),
|
||||
ip: d.lastIp || "?",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{canRevoke && d.active && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost ml-auto h-7 px-2 text-xs text-status-danger"
|
||||
onClick={() => setRevoking(d)}
|
||||
>
|
||||
{t("companyHub.revoke")}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{groups.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-semibold text-fg-secondary">{t("companyHub.sessions")}</h4>
|
||||
{groups.map(([device, folders]) => (
|
||||
<div key={device} className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-fg-primary">{device}</div>
|
||||
{[...folders.entries()].map(([folder, rows]) => (
|
||||
<div key={folder} className="ml-3 space-y-1">
|
||||
<div className="truncate font-mono text-[11px] text-fg-muted" title={folder}>
|
||||
{folder}
|
||||
</div>
|
||||
<ul className="ml-3 space-y-0.5">
|
||||
{rows.map((s) => (
|
||||
<li
|
||||
key={s.sessionId}
|
||||
className="flex flex-wrap items-center gap-2 text-[11px]"
|
||||
>
|
||||
{s.purgedAt ? (
|
||||
<span className="text-fg-muted">
|
||||
{s.title || s.sessionId.slice(0, 8)}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
to={`/sessions/${s.sessionId}`}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{s.title || s.sessionId.slice(0, 8)}
|
||||
</Link>
|
||||
)}
|
||||
{s.unmonitored ? (
|
||||
<span className="inline-flex items-center gap-1 text-status-warning">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
{t("companyHub.unmonitored")}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-fg-muted">
|
||||
{t("companyHub.sessionLine", {
|
||||
requests: s.requests,
|
||||
input: fmt(s.inputTokens + s.cacheReadTokens + s.cacheWriteTokens),
|
||||
output: fmt(s.outputTokens),
|
||||
account: s.account || "—",
|
||||
})}
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
· {formatDateTime(s.lastRequestAt || s.registeredAt)}
|
||||
</span>
|
||||
{s.purgedAt && (
|
||||
<span className="text-fg-muted">· {t("companyHub.purged")}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{revoking && (
|
||||
<RevokeDialog
|
||||
device={revoking}
|
||||
onClose={() => setRevoking(null)}
|
||||
onDone={() => {
|
||||
setRevoking(null);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RevokeDialog({
|
||||
device,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
device: RemoteDevice;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async () => {
|
||||
if (!password || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.remoteHub.revoke(device.id, password);
|
||||
toast.success(t("companyHub.revoked", { name: device.name }));
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("companyHub.revokeTitle", { name: device.name })}
|
||||
message={t("companyHub.revokeHint")}
|
||||
confirmLabel={t("companyHub.revoke")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!password}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-2"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">
|
||||
{t("companyHub.yourPassword")}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("companyHub.yourPassword")}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user