apps/console —— 跨端(macOS/Windows)桌面控制台,复用 ops 侧的 Node agent 作本地被控端,
不做第二套 agent。技术栈:Tauri 2(Rust 壳 + 系统 WebView,包体小)+
Vue 3 + TS + Vite + Pinia + Naive UI(暗色)+ Vue Router(hash)。
- command 层(src-tauri/src/lib.rs):connect / disconnect / invoke_capability /
get_capabilities / get_state;client 事件经 Emitter 桥接为 na-log / na-state / na-event 推前端。
握手后**先 await 成功再落 state**(不持锁 await)。
- 7 页面:设备管理 / 状态总览 / 终端 / 进程与服务 / 软件 / 文件 / 审计。
- 实测:macOS .app 6.75 MiB(方案指标 <30MB;Electron 同级约 100MB+),
直接执行二进制进程存活、无崩溃 —— `codesign --verify --deep --strict` 那条
"code has no resources but signature indicates they must be present" 是 adhoc
链接签名的固有噪声,实测无害(勿被误导去「修签名」)。
⚠️ 本轮用 examples/probe.rs 对真实被控端核对字段契约,抓出 9 处**静默空列**
(不报错、类型系统也抓不到,界面只显示 "-"),全部按契约修掉:
- 进程表读 r.cpu / r.memory → 真源 cpu_pct / memory_bytes(CPU、内存两列恒为 "-")
- 服务表读 r.status → 真源 state
- 磁盘读 d.mount||d.name / d.used → 真源只有 drive + total/free,
**没有 used**(须自算 total-free)
- 网卡读 {name,rx_bytes,tx_bytes} → 真源 {adapter,ip,up}
- 软件表读 a.id → app.list 结果**没有 id**,应为 publisher
- 审计 broken_at 当数字渲染 → 真源是 {file,line,reason} 对象
另清掉幻影字段 SystemInfo.node(契约里只有 node_path)。
配套两点设计:
- **平台门控**:连接后探测 system.info.os → isWindows/platformReady;app.* 与
system.service.list 仅 Windows 被控端可用,故软件页与服务 Tab **事前禁用并说明原因**,
而不是发出注定失败的请求再解释错误。
- **结构化错误**:协议真源 InvokeResult.error 只有 {name,message,data?}(**没有 code**),
故分支一律按 name;前端 CapabilityFailure 暴露 errorName,并提供
isUnsupportedPlatform / isAclDenied / isRateLimited。未改协议(改动会扩散 4 端)。
tests/unit/console-contract.test.mjs(20 例):把「前端声明 ⊆ 契约」与「契约 ⊆ 前端声明」
双向钉死,另校验平台门控清单与 agent 侧 requireWindows 集合一致、AuditType 覆盖
apps/agent/src/audit.ts 全部取值。**已注入漂移实测会红**(把 cpu_pct 改回 cpu →
正/反向各挂 1 项),非空转守卫。
241 lines
6.8 KiB
Vue
241 lines
6.8 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, ref } from "vue";
|
||
import {
|
||
NCard,
|
||
NInput,
|
||
NButton,
|
||
NSpace,
|
||
NText,
|
||
NEmpty,
|
||
NDataTable,
|
||
NModal,
|
||
NTag,
|
||
useMessage,
|
||
useDialog,
|
||
type DataTableColumns,
|
||
} from "naive-ui";
|
||
import { useConnStore } from "../stores/conn";
|
||
import type { FsEntry, FsListResult, FsReadResult, SystemInfo } from "../types";
|
||
import { formatBytes, formatTime } from "../utils/format";
|
||
|
||
const conn = useConnStore();
|
||
const msg = useMessage();
|
||
const dialog = useDialog();
|
||
|
||
const cwd = ref("");
|
||
const entries = ref<FsEntry[]>([]);
|
||
const loading = ref(false);
|
||
const truncated = ref(false);
|
||
|
||
const previewOpen = ref(false);
|
||
const previewName = ref("");
|
||
const previewText = ref("");
|
||
const previewMeta = ref("");
|
||
|
||
const writeOpen = ref(false);
|
||
const wName = ref("");
|
||
const wContent = ref("");
|
||
const wAppend = ref(false);
|
||
|
||
const cols: DataTableColumns<FsEntry> = [
|
||
{
|
||
title: "名称",
|
||
key: "name",
|
||
render: (r) => r.name + (r.type === "dir" ? "/" : ""),
|
||
},
|
||
{ title: "类型", key: "type", width: 80 },
|
||
{ title: "大小", key: "size", width: 110, render: (r) => (r.type === "file" ? formatBytes(r.size) : "-") },
|
||
{ title: "修改时间", key: "mtime", width: 180, render: (r) => formatTime(r.mtime) },
|
||
];
|
||
|
||
async function list(path: string) {
|
||
if (!path) return;
|
||
loading.value = true;
|
||
try {
|
||
const r = await conn.ok<FsListResult>("fs.list", { path });
|
||
entries.value = (r.entries ?? []).slice().sort((a, b) => {
|
||
if (a.type === "dir" && b.type !== "dir") return -1;
|
||
if (a.type !== "dir" && b.type === "dir") return 1;
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
truncated.value = !!r.truncated;
|
||
cwd.value = path;
|
||
} catch (e) {
|
||
msg.error(`列目录失败:${e}`);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function parentOf(p: string): string {
|
||
const s = p.replace(/[\\/]+$/, "");
|
||
const idx = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
|
||
if (idx < 0) return s;
|
||
if (idx === 0) return s.slice(0, 1); // POSIX 根
|
||
const r = s.slice(0, idx);
|
||
return /^[A-Za-z]:$/.test(r) ? r + "\\" : r; // Windows 盘符
|
||
}
|
||
|
||
function onRowClick(row: FsEntry) {
|
||
if (row.type === "dir") list(row.path);
|
||
else preview(row);
|
||
}
|
||
|
||
async function preview(row: FsEntry) {
|
||
try {
|
||
const r = await conn.ok<FsReadResult>("fs.read", {
|
||
path: row.path,
|
||
encoding: "utf8",
|
||
max_bytes: 262144,
|
||
});
|
||
previewName.value = row.name;
|
||
previewText.value = (r.data ?? "").replace(/\u0000/g, "");
|
||
previewMeta.value = `${formatBytes(r.total_bytes)}${r.eof ? "" : " · 已截断预览"}${r.sha256 ? ` · sha256 ${r.sha256.slice(0, 12)}…` : ""}`;
|
||
previewOpen.value = true;
|
||
} catch (e) {
|
||
msg.error(`读取失败:${e}`);
|
||
}
|
||
}
|
||
|
||
function openWrite() {
|
||
wName.value = "";
|
||
wContent.value = "";
|
||
wAppend.value = false;
|
||
writeOpen.value = true;
|
||
}
|
||
|
||
function doWrite() {
|
||
if (!wName.value) return;
|
||
const sep = cwd.value.includes("\\") ? "\\" : "/";
|
||
const path = cwd.value.replace(/[\\/]+$/, "") + sep + wName.value;
|
||
dialog.warning({
|
||
title: "确认写入被控端",
|
||
content: `目标:${path}\n模式:${wAppend.value ? "追加" : "覆盖"}\n字节:${wContent.value.length}`,
|
||
positiveText: "写入",
|
||
negativeText: "取消",
|
||
onPositiveClick: () => writeTo(path),
|
||
});
|
||
}
|
||
|
||
async function writeTo(path: string) {
|
||
try {
|
||
await conn.ok("fs.write", { path, data: wContent.value, append: wAppend.value, create_dirs: true });
|
||
msg.success(`已写入 ${path}`);
|
||
writeOpen.value = false;
|
||
list(cwd.value);
|
||
} catch (e) {
|
||
msg.error(`写入失败:${e}`);
|
||
}
|
||
}
|
||
|
||
async function ensureStart() {
|
||
try {
|
||
const info = await conn.ok<SystemInfo>("system.info", { fields: ["agent_home", "os"] });
|
||
let start = info.agent_home ?? "";
|
||
if (!start) start = info.os === "Windows" ? "C:\\" : "/";
|
||
list(start);
|
||
} catch {
|
||
list(conn.info ? "/" : "");
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
if (conn.connected) ensureStart();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="!conn.connected">
|
||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||
</div>
|
||
|
||
<div v-else class="wrap">
|
||
<n-card size="small">
|
||
<n-space align="center" :wrap="false">
|
||
<n-input
|
||
v-model:value="cwd"
|
||
size="small"
|
||
placeholder="输入路径,回车列目录(如 C:\ 或 /Users)"
|
||
style="flex: 1"
|
||
@keyup.enter="list(cwd)"
|
||
/>
|
||
<n-button size="small" @click="list(parentOf(cwd))">上级</n-button>
|
||
<n-button size="small" :loading="loading" @click="list(cwd)">刷新</n-button>
|
||
<n-button size="small" type="primary" @click="openWrite">新建文件</n-button>
|
||
</n-space>
|
||
<div class="path">
|
||
<n-text depth="3" style="font-size: 12px">
|
||
当前:{{ cwd || "-" }} · {{ entries.length }} 项
|
||
<n-tag v-if="truncated" size="tiny" type="warning" style="margin-left: 6px">结果被截断</n-tag>
|
||
</n-text>
|
||
</div>
|
||
</n-card>
|
||
|
||
<n-card size="small" style="margin-top: 16px">
|
||
<n-empty v-if="!entries.length" description="目录为空或未加载" size="small" />
|
||
<n-data-table
|
||
v-else
|
||
:columns="cols"
|
||
:data="entries"
|
||
:bordered="false"
|
||
size="small"
|
||
:max-height="520"
|
||
:row-props="(row: FsEntry) => ({ style: 'cursor:pointer', onClick: () => onRowClick(row) })"
|
||
/>
|
||
</n-card>
|
||
</div>
|
||
|
||
<!-- 预览 -->
|
||
<n-modal v-model:show="previewOpen" preset="card" :title="previewName" style="width: 820px; max-width: 90vw">
|
||
<n-text depth="3" style="font-size: 12px">{{ previewMeta }}</n-text>
|
||
<pre class="preview">{{ previewText || "(空文件)" }}</pre>
|
||
</n-modal>
|
||
|
||
<!-- 新建/写入 -->
|
||
<n-modal v-model:show="writeOpen" preset="card" title="写入文件" style="width: 640px; max-width: 90vw">
|
||
<n-space vertical>
|
||
<n-input v-model:value="wName" placeholder="文件名(写入当前目录)" size="small" />
|
||
<n-input
|
||
v-model:value="wContent"
|
||
type="textarea"
|
||
:autosize="{ minRows: 6, maxRows: 16 }"
|
||
placeholder="文件内容(utf8)"
|
||
/>
|
||
<n-space align="center">
|
||
<n-tag
|
||
size="small"
|
||
:type="wAppend ? 'warning' : 'default'"
|
||
checkable
|
||
:checked="wAppend"
|
||
@update:checked="(v: boolean) => (wAppend = v)"
|
||
>
|
||
追加模式
|
||
</n-tag>
|
||
<n-button size="small" type="primary" @click="doWrite">写入</n-button>
|
||
</n-space>
|
||
</n-space>
|
||
</n-modal>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.wrap {
|
||
max-width: 1100px;
|
||
}
|
||
.path {
|
||
margin-top: 10px;
|
||
}
|
||
.preview {
|
||
margin: 12px 0 0;
|
||
padding: 12px;
|
||
border-radius: 8px;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
font-family: ui-monospace, Menlo, monospace;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
max-height: 60vh;
|
||
overflow: auto;
|
||
}
|
||
</style>
|