chore(copy): tighten 1.8 wording, drop the "was X (not Y)" asides

- admin CPU card: the "what saving does depends on the checkbox" footnote is
  gone; the state line says it instead, and the state/description text is
  shorter
- user panel: the reinstall share field is just "或从分享的检查点安装" and its
  note "将从该检查点恢复,不安装系统。"; the share warning no longer parenthesises
  the contents ("拿到分享码的人可以读取该检查点内的全部数据,包括容器里的凭据。"),
  the second confirmation is "确认删除 / 更早的 N 个检查点会被删除,无法恢复。",
  the revoke prompt drops the repeated question, and the restore/reset prompts
  lose their "…,确认?"/"(重置后无法恢复)" tails
- the share toast only mentions removed checkpoints when there were some
  (previously "share code created (0 older checkpoints removed)")
- expiry: "账户已到期,机器已关机并锁定。" and "该账户已到期,仅可延期。";the
  "new deadline = max(now, current) + duration" footnote and "(0 = permanent)"
  parentheticals are gone
- knowledge editor: label is a plain "Markdown: headings, lists, …" legend and
  the empty preview is a dash instead of an instruction
- docs: the 1.8 sections (expiry, snapshot sharing, refquota, sessions, CPU
  rule, knowledge base) are cut roughly in half

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-09-16 07:12:06 +00:00
1 parent 3c48c27cc3
commit c2009ddc69
9 files changed
+97 -110

No files matched your search

+38 -54
View File
@@ -128,17 +128,13 @@ The panel daemon runs as the dedicated unprivileged `vps` system user
can only grow, never shrink.
- **Quota validity (expiry)**: each user can carry an optional absolute deadline
(`users.expires_at`, RFC3339 UTC, `''` = permanent). When it passes, the 60s
loop force-stops the container (`stop` with `force:true`) and disables
`boot.autostart`, and the account is locked to read-only for both the user and
the admin — the only operations left are the admin extending the deadline
(extend = `max(now, current) + duration`), deleting the account, and either
side changing the **panel login password** (the user via the panel, the admin
via reset-password), which stays available so the account never becomes
unreachable. Extending past the deadline lifts the lock; a manual start is
then required (autostart stays off). The user panel shows a countdown under
the bandwidth bar; the admin users table dims expired rows and tags the name
`(-Nd)`. The container and its snapshots are never deleted, so a checkpoint
shared before expiry stays installable by others.
loop force-stops the container and disables `boot.autostart`, and the account
goes read-only for both sides. The admin can still extend it
(`max(now, current) + duration`), delete the account, and reset its panel
password; the user can still change their own panel password. Extending lifts
the lock but leaves autostart off, so the container needs a manual start. The
container and its snapshots are never deleted, so a checkpoint shared before
expiry stays installable by others.
- **Container swap**: Incus 7 on cgroup v2 writes `memory.swap.max=0` for every
container unless `limits.memory.swap` carries an explicit byte amount
(`true`/`false` both end up as 0), so without an explicit value containers
@@ -265,26 +261,20 @@ The panel daemon runs as the dedicated unprivileged `vps` system user
carries the checkpoint's state).
Cloning from a checkpoint **pins** it: ZFS cannot roll the source back past a
snapshot that a clone depends on. Publishing therefore deletes the older
checkpoints (the panel warns with the exact count first — they could never be
restored to again once a clone exists) and keeps only the published one. On
deleting the snapshot, reinstalling or deleting the container, the share is
revoked with it (`ReinstallFromShare` reports a clear error rather than
building a half-container). A code that points at the **same** container being
reinstalled is treated as a plain restore, since cloning-then-deleting would
destroy the snapshot first.
snapshot a clone depends on. Publishing therefore deletes the older
checkpoints, keeping only the published one; the panel warns with the exact
count first. Deleting the checkpoint, reinstalling or deleting the container
revokes the share with it. A code pointing at the **same** container being
reinstalled is a plain restore (cloning first would destroy the snapshot).
The whole feature is **opt-in and off by default** (new installs and upgrades
alike): enable it with `vps config set snapshots.share true`. It is a CLI-only
setting — there is no admin-panel UI for it. While off, the user-side
share/import entry points are hidden and the manager refuses new shares and
installs, but the stored codes are kept and every container and checkpoint is
left alone, so switching it back on restores the codes for as long as their
checkpoint still exists. The code→checkpoint mapping is only ever removed when
the checkpoint itself is deleted (from the panel) or the container goes away,
and a code whose checkpoint has vanished is reported as invalid instead of
half-building a container. Installs are audited as `reinstall.share.<owner>`,
sharing as `snapshot.share.<checkpoint>`.
The feature is **off by default** and has no panel UI: enable it with `vps
config set snapshots.share true`. While off, the user-side entry points are
hidden and new shares and installs are refused; stored codes and every
container and checkpoint are left alone, so re-enabling restores the codes as
long as their checkpoint exists. A code is dropped only when its checkpoint is
deleted or its container goes away, and a code whose checkpoint has vanished
fails with an error instead of half-building a container. Installs are audited
as `reinstall.share.<owner>`, shares as `snapshot.share.<checkpoint>`.
### User groups (multi-container users)
@@ -364,14 +354,12 @@ no driver branch in the panel code, and the single driver-aware helper
format, or modify secondary disks. The loop file only allocates blocks as the
pool actually fills. On very small hosts, cap the ZFS ARC (`zfs.arc_max`) so
container memory keeps priority over the pool's cache. New containers get a
**hard** disk limit: the pool carries `volume.zfs.use_refquota=true` (set by
`10-incus.sh` on creation and re-asserted on every run, plus an
`EnsurePoolRefQuota` pass in `vps install`), so a volume's disk usage counts
every block it references — including blocks inherited from the image or a
shared checkpoint through CoW clones. Without it, ZFS's default `quota`
charges a clone only for its own delta, so a limit would not match what the
user sees inside the container. Existing volumes are untouched: pool defaults
apply to new volumes only.
**hard** disk limit: the pool sets `volume.zfs.use_refquota=true`
(`10-incus.sh` plus an `EnsurePoolRefQuota` pass in `vps install`), so a
volume's usage counts every block it references, including blocks shared
through CoW clones. ZFS's default `quota` charges a clone only for its own
delta, which would stop matching what the user sees inside the container.
Existing volumes are untouched — pool defaults apply to new ones only.
- **btrfs (beta)**. Selecting `VPSMGR_STORAGE=btrfs` is flagged as beta and
`install.sh` asks for an explicit confirmation up front (default yes — on a
btrfs root there is no ZFS fallback). When `/` is itself a btrfs filesystem,
@@ -537,16 +525,13 @@ into the kernel.
bare, headerless 404 (no fingerprint, no auth cost).
- Mutating actions are POST-only; sessions are 3-day HttpOnly+Secure+
SameSite=Lax cookies; a per-IP login rate limiter.
- Sessions are **persisted in the DB** (only the SHA-256 of the token is stored,
so a copy of the database cannot be replayed): user sessions in `sessions`
(→ `users`), admin sessions in `admin_sessions` (password-only auth, no user
row). A panel restart — upgrade, crash, `systemctl restart vps` — therefore no
longer logs either side out. Rotation still invalidates the other sessions: an
admin password change drops every admin session but the one that made it.
- The **dynamic CPU limit rule** is panel-owned: it lives in the DB settings
table and is edited on the admin overview, not in `config.yaml` (no
`vps config` key either). Saving applies it immediately; `vps install` leaves
it alone so an upgrade never resets it.
- Sessions are persisted in the DB (hashed tokens, so a copy of the database
cannot be replayed): user sessions in `sessions`, admin sessions in
`admin_sessions`. A panel restart does not log either side out. An admin
password change still drops every other admin session.
- The dynamic CPU limit rule is panel-owned: a DB settings row edited on the
admin overview, with no `config.yaml` key. Saving applies it at once, and
`vps install` never resets it.
- The panel daemon is **unprivileged** (see "Unprivileged panel" above): Incus
access via group membership, only whitelisted commands via sudo.
- Containers are Incus-unprivileged with `security.nesting=true`.
@@ -556,12 +541,11 @@ into the kernel.
The operator can author Markdown articles in the admin panel (`/knowledge`);
users open them read-only from a **知识库 / Knowledge base** button on the
machine card, which opens a large overlay (nearly fullscreen on phones). Content
is stored verbatim in the `knowledge` table and rendered **server-side** by
`internal/markdown` — a hand-written, dependency-free renderer for a basic
subset (headings, fenced code, inline code, bold/italic, links, lists,
blockquotes, rules). It escapes HTML first and only allows http/https/mailto/#/
links, so article content can never inject markup. The rendered HTML is embedded
in the panel page; a small script adds a copy button to each code block.
lives in the `knowledge` table and is rendered **server-side** by
`internal/markdown`, a dependency-free renderer for a basic subset (headings,
fenced code, inline code, bold/italic, links, lists, quotes, rules). It escapes
HTML first and allows only http/https/mailto/#/ links, so articles cannot inject
markup. A small script adds a copy button to each code block.
## Bandwidth accounting
+5 -9
View File
@@ -53,18 +53,14 @@ same table; `vps config list` shows the live values with this annotation.
| `incus.socket` | operator | restart panel | Incus daemon Unix socket |
| `incus.swap_ratio` | operator | **applied immediately** | swap granted to each container as a multiple of its memory limit (`limits.memory.swap = limits.memory × ratio`); `0` disables container swap. Setting it re-applies the allowance to **all existing containers** (no restart) |
| `snapshots.limit` | operator | restart panel | max checkpoints a user may keep per container (`0` = disable new snapshots). Restoring to an older checkpoint auto-deletes the ones created after it (see below) |
| `snapshots.share` | operator | **applied immediately** | allow users to share a checkpoint behind a code and install from it. **Off by default** (new installs and upgrades alike) and CLI-only — no panel UI: `vps config set snapshots.share true`. Stored codes are kept while it is off |
| `snapshots.share` | operator | **applied immediately** | allow users to share a checkpoint behind a code and install from it. **Off by default**; CLI-only: `vps config set snapshots.share true`. Stored codes survive while it is off |
### Settings that are NOT in this file
The **dynamic CPU limit rule** (cap a container to Z cores for N after it stays
over Y% of its own quota for X minutes in a row) is edited in the **admin
panel** (the "Dynamic CPU limit rule" card on the overview) and stored in the
DB — it is deliberately not a `config.yaml` option and has no `vps config` key,
because it is tuned repeatedly and must apply immediately. Saving it applies the
new rule on the spot (any container already over the threshold is capped, and
turning the rule off restores every capped container), and `vps install` never
resets it.
The **dynamic CPU limit rule** is edited on the admin overview and stored in the
DB: it has no `config.yaml` key and no `vps config` key, because it is tuned
repeatedly and must apply immediately. Saving applies it on the spot (turning it
off restores every capped container), and `vps install` never resets it.
### How "fixed at install" is enforced
+7 -4
View File
@@ -97,10 +97,13 @@ func TestCPULimitCardSave(t *testing.T) {
if rr.Code != http.StatusFound {
t.Fatalf("disable save = %d, want 302", rr.Code)
}
// The message must not claim the rule is in effect when it is off.
if msg := flashMsg(t, h, prefix, cookie); !strings.Contains(msg, "未启用") &&
!strings.Contains(msg, "no container is capped") {
t.Errorf("disabled save flash = %q, want it to say the rule is off", msg)
// The message must match what happened: off means nothing was applied.
offMsg := flashMsg(t, h, prefix, cookie)
if !strings.Contains(offMsg, "未启用") && !strings.Contains(offMsg, "disabled") {
t.Errorf("disabled save flash = %q, want it to say the rule is off", offMsg)
}
if strings.Contains(offMsg, "立即生效") || strings.Contains(offMsg, "applied now") {
t.Errorf("disabled save claims the rule is in effect: %q", offMsg)
}
off := want
off.Enabled = false
+4 -4
View File
@@ -106,13 +106,13 @@ func tr(l, key string, args ...any) string {
"knowledge_deleted": {"知识库文章已删除", "knowledge-base article deleted"},
"err_kb_title": {"标题不能为空", "the title is required"},
"err_kb_content": {"内容不能为空", "the content is required"},
"err_kb_too_large": {"内容过大(上限 256 KiB)", "the content is too large (256 KiB max)"},
"err_invalid_days": {"有效期天数必须是非负整数(0 = 无限期)", "validity days must be a non-negative integer (0 = permanent)"},
"err_kb_too_large": {"内容超过 256 KiB", "the content exceeds 256 KiB"},
"err_invalid_days": {"有效期天数必须是非负整数", "validity days must be a non-negative integer"},
"err_invalid_extend": {"延期时长无效", "invalid extension duration"},
"quota_expiry_updated": {"已更新 %[1]v 的有效期", "validity updated for %[1]v"},
"err_account_expired": {"账户已到期,仅可延期或删除", "account expired — only extend or delete is allowed"},
"cpu_limit_saved": {"CPU 动态限制规则已保存并立即生效", "dynamic CPU limit rule saved and applied now"},
"cpu_limit_saved_off": {"CPU 动态限制规则已保存:当前未启用,不限制任何容器", "dynamic CPU limit rule saved — it is disabled, so no container is capped"},
"cpu_limit_saved": {"规则已保存并立即生效", "rule saved and applied now"},
"cpu_limit_saved_off": {"规则已保存,当前未启用", "rule saved; it is disabled"},
}
pair, ok := m[key]
if !ok {
@@ -110,7 +110,7 @@ label { display: block; font-size: 12px; color: #6b7280; margin: 10px 0 4px; }
<input type="hidden" name="id" value="{{if .Editing}}{{.Editing.ID}}{{end}}">
<label for="kbTitle">{{if eq .Lang "zh"}}标题{{else}}Title{{end}}</label>
<input id="kbTitle" name="title" maxlength="200" required value="{{if .Editing}}{{.Editing.Title}}{{end}}">
<label for="kbContent">{{if eq .Lang "zh"}}内容(Markdown:标题、列表、代码块、粗体/斜体、链接、引用){{else}}Content (Markdown: headings, lists, code blocks, bold/italic, links, quotes){{end}}</label>
<label for="kbContent">{{if eq .Lang "zh"}}Markdown:标题、列表、代码块、粗体/斜体、链接、引用{{else}}Markdown: headings, lists, code blocks, bold/italic, links, quotes{{end}}</label>
<textarea id="kbContent" name="content" spellcheck="false" required>{{.Content}}</textarea>
<div class="growbar">
<button type="button" class="btn" id="kbPreviewBtn">{{if eq .Lang "zh"}}预览{{else}}Preview{{end}}</button>
@@ -120,7 +120,7 @@ label { display: block; font-size: 12px; color: #6b7280; margin: 10px 0 4px; }
</form>
<div class="growbar">
<div class="muted" style="font-size:12px;margin-bottom:6px">{{if eq .Lang "zh"}}预览{{else}}Preview{{end}}</div>
<div class="md" id="kbPreview">{{if eq .Lang "zh"}}点击“预览”查看渲染结果。{{else}}Click “Preview” to render.{{end}}</div>
<div class="md" id="kbPreview">{{if eq .Lang "zh"}}—{{else}}—{{end}}</div>
</div>
</div>
</main>
@@ -232,7 +232,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
<div class="field create-field resource-field"><label>{{if eq .Lang "zh"}}内存 MiB{{else}}Memory MiB{{end}}</label><input name="mem" id="cfMem" value="1024" required></div>
<div class="field create-field resource-field"><label>{{if eq .Lang "zh"}}磁盘 GiB{{else}}Disk GiB{{end}}</label><input name="disk" id="cfDisk" type="number" min="1" value="10" required></div>
<div class="field create-field"><label>{{if eq .Lang "zh"}}流量配额 GiB{{else}}Bandwidth GiB{{end}}</label><input name="bandwidth" id="cfBandwidth" type="number" min="0" step="1" value="0" title="{{if eq .Lang "zh"}}每月流量上限(上传+下载),0 = 不限;超限后限速 1Mbps{{else}}monthly quota (upload+download), 0 = unlimited; throttled to 1Mbps when exceeded{{end}}"></div>
<div class="field create-field"><label>{{if eq .Lang "zh"}}有效期(天){{else}}Validity (days){{end}}</label><input name="days" id="cfDays" type="number" min="0" step="1" value="0" title="{{if eq .Lang "zh"}}从创建起计算的有效天数,0 = 无限期{{else}}validity in days from creation; 0 = permanent{{end}}"></div>
<div class="field create-field"><label>{{if eq .Lang "zh"}}有效期(天){{else}}Validity (days){{end}}</label><input name="days" id="cfDays" type="number" min="0" step="1" value="0" title="{{if eq .Lang "zh"}}天数,0 = 无限期{{else}}days; 0 = permanent{{end}}"></div>
{{if .IPv6PoolMode}}
<div class="field create-field ipv6-field"><label>{{if eq .Lang "zh"}}IPv6 地址{{else}}IPv6 address{{end}}</label>
<select name="ipv6" id="cfIpv6" class="field-select">
@@ -325,13 +325,13 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
<div class="card">
<h2>{{if eq .Lang "zh"}}CPU 动态限制规则(全局){{else}}Dynamic CPU limit rule (global){{end}}</h2>
<form action="{{.Prefix}}/cpu-limit" method="post" id="cpuForm">
<p style="margin:0 0 10px">{{if eq .Lang "zh"}}启用后:容器连续 <b>X</b> 分钟占用超过自身配额的 <b>Y%</b> 时,限制到 <b>Z</b> 核,持续 <b>N</b> 后自动恢复原配额。配额本身不高于 Z 的容器会被跳过。{{else}}Once enabled: when a container stays above <b>Y%</b> of its own quota for <b>X</b> minutes, it is capped to <b>Z</b> core(s) for <b>N</b>, then its quota is restored. Containers whose own quota is at or below Z are skipped.{{end}}</p>
<p style="margin:0 0 10px">{{if eq .Lang "zh"}}启用后:容器连续 <b>X</b> 分钟超过自身配额 <b>Y%</b> 时,限制到 <b>Z</b> 核,持续 <b>N</b> 后恢复原配额。配额不高于 Z 的容器会跳过。{{else}}Once enabled: a container that stays above <b>Y%</b> of its own quota for <b>X</b> minutes is capped to <b>Z</b> core(s) for <b>N</b>, then its quota is restored. Containers whose quota is at or below Z are skipped.{{end}}</p>
<label style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
<input type="checkbox" name="enabled" id="cpuEnabled" {{if .CPULimitEnabled}}checked{{end}} style="width:auto">
<span>{{if eq .Lang "zh"}}启用动态限制{{else}}Enable the dynamic limit{{end}}</span>
</label>
<p id="cpuStateOff" class="cpu-state off" {{if .CPULimitEnabled}}hidden{{end}}>{{if eq .Lang "zh"}}当前<b>未启用</b>:所有容器按各自配额运行,不做任何限制。{{else}}Currently <b>disabled</b>: every container runs on its own quota, nothing is capped.{{end}}</p>
<p id="cpuStateOn" class="cpu-state on" {{if not .CPULimitEnabled}}hidden{{end}}>{{if eq .Lang "zh"}}当前<b>已启用</b>,保存后立即生效(判定基于每分钟采样的 CPU 使用率,最多延迟 1 分钟触发)。{{else}}Currently <b>enabled</b>; saving applies it at once (judged from the per-minute CPU samples, so it can take up to a minute to trigger).{{end}}</p>
<p id="cpuStateOff" class="cpu-state off" {{if .CPULimitEnabled}}hidden{{end}}>{{if eq .Lang "zh"}}未启用:不限制任何容器。{{else}}Disabled: no container is capped.{{end}}</p>
<p id="cpuStateOn" class="cpu-state on" {{if not .CPULimitEnabled}}hidden{{end}}>{{if eq .Lang "zh"}}已启用,保存后立即生效,最多延迟 1 分钟触发。{{else}}Enabled: saving applies it at once; it can take a minute to trigger.{{end}}</p>
<div id="cpuFields" {{if not .CPULimitEnabled}}hidden{{end}}>
<div class="field-row" style="display:flex;gap:14px;flex-wrap:wrap">
<div class="field" style="flex:1;min-width:150px">
@@ -357,7 +357,6 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
</div>
<div style="margin-top:12px">
<button type="submit" class="primary neutral">{{if eq .Lang "zh"}}保存{{else}}Save{{end}}</button>
<small style="color:#9aa0a8;margin-left:10px">{{if eq .Lang "zh"}}保存后的效果取决于上方的勾选状态。{{else}}What saving does depends on the checkbox above.{{end}}</small>
</div>
</form>
<div style="margin-top:14px">
@@ -402,7 +401,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
<div class="field"><label>{{if eq .Lang "zh"}}内存 MiB{{else}}Memory MiB{{end}}</label><input type="number" id="planMem" min="1" step="1" required></div>
<div class="field"><label>{{if eq .Lang "zh"}}磁盘 GiB{{else}}Disk GiB{{end}}</label><input type="number" id="planDisk" min="1" step="1" required></div>
<div class="field"><label>{{if eq .Lang "zh"}}流量 GiB /月{{else}}Bandwidth GiB/mo{{end}}</label><input type="number" id="planBw" min="0" step="1" required title="{{if eq .Lang "zh"}}每月流量上限(上传+下载),0 = 不限{{else}}monthly quota (up+down), 0 = unlimited{{end}}"></div>
<div class="field"><label>{{if eq .Lang "zh"}}有效期(天){{else}}Validity (days){{end}}</label><input type="number" id="planDays" min="0" step="1" value="0" title="{{if eq .Lang "zh"}}从创建起计算的有效天数,0 = 无限期{{else}}validity in days from creation; 0 = permanent{{end}}"></div>
<div class="field"><label>{{if eq .Lang "zh"}}有效期(天){{else}}Validity (days){{end}}</label><input type="number" id="planDays" min="0" step="1" value="0" title="{{if eq .Lang "zh"}}天数,0 = 无限期{{else}}days; 0 = permanent{{end}}"></div>
<div class="foot" style="justify-content:flex-end">
<button type="button" onclick="closePlan()">{{if eq .Lang "zh"}}取消{{else}}Cancel{{end}}</button>
<button type="submit" class="primary neutral">{{if eq .Lang "zh"}}保存{{else}}Save{{end}}</button>
@@ -426,7 +425,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
<button type="submit" class="primary" id="quotaSaveBtn">{{if eq .Lang "zh"}}保存{{else}}Save{{end}}</button>
<button type="button" onclick="closeQuota()">{{if eq .Lang "zh"}}取消{{else}}Cancel{{end}}</button>
</form>
<p id="quotaExpiredNote" style="display:none;color:#b91c1c;font-weight:600;margin:10px 0 0">{{if eq .Lang "zh"}}该账户已到期:仅可延期,其他配额与操作已锁定。{{else}}This account has expired: only extending is allowed; everything else is locked.{{end}}</p>
<p id="quotaExpiredNote" style="display:none;color:#b91c1c;font-weight:600;margin:10px 0 0">{{if eq .Lang "zh"}}该账户已到期,仅可延期。{{else}}This account has expired — only extending is allowed.{{end}}</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:14px 0;">
<form action="{{.Prefix}}/user-expiry" method="post" id="expiryForm" class="form-grid">
<input type="hidden" name="name" id="quotaExpiryName">
@@ -438,12 +437,12 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
<option value="90d">90 {{if eq .Lang "zh"}}天{{else}}days{{end}}</option>
<option value="180d">180 {{if eq .Lang "zh"}}天{{else}}days{{end}}</option>
<option value="custom">{{if eq .Lang "zh"}}自定义(天){{else}}Custom (days){{end}}</option>
<option value="forever">{{if eq .Lang "zh"}}永久(清除到期){{else}}Permanent (clear){{end}}</option>
<option value="forever">{{if eq .Lang "zh"}}永久{{else}}Permanent{{end}}</option>
</select>
</div>
<div class="field" id="quotaExtendDaysWrap" style="display:none"><label>{{if eq .Lang "zh"}}自定义天数{{else}}Custom days{{end}}</label><input name="days" id="quotaExtendDays" type="number" min="1" step="1" value="30"></div>
<button type="submit" class="primary neutral">{{if eq .Lang "zh"}}应用延期{{else}}Apply extension{{end}}</button>
<small style="color:#9aa0a8;display:block">{{if eq .Lang "zh"}}新到期时间 = max(现在, 当前到期) + 所选时长{{else}}new deadline = max(now, current) + chosen duration{{end}}</small>
</form>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:14px 0;">
<form action="{{.Prefix}}/user-bandwidth-reset" method="post">
+5 -1
View File
@@ -611,7 +611,11 @@ func (s *Server) handleSnapshotShare(w http.ResponseWriter, r *http.Request) {
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "snapshot.share."+name)
s.redirect(w, r, s.p(""), s.t(r, "snapshot_shared", len(removed)))
msg := "snapshot_shared"
if len(removed) > 0 {
msg = "snapshot_shared_removed"
}
s.redirect(w, r, s.p(""), s.t(r, msg, len(removed)))
}
// handleSnapshotUnshare revokes the user's share code.
+13 -12
View File
@@ -81,18 +81,19 @@ func (s *Server) t(r *http.Request, key string, args ...any) string {
// zh and one en variant toggled by {{if eq .Lang "zh"}}.
func tr(l, key string, args ...any) string {
m := map[string][2]string{ // key -> [zh, en]
"err_bad_login": {"用户名或密码错误", "invalid credentials"},
"err_too_many": {"尝试过于频繁,请 1 分钟后再试", "Too many attempts, please wait 1 minute"},
"err_pass_mismatch": {"两次输入的密码不一致", "The two passwords do not match"},
"err_pass_short": {"密码长度不得低于 10 位", "password must be at least 10 characters"},
"err_pass_weak": {"密码需同时包含大写字母、小写字母和数字", "password must contain uppercase letters, lowercase letters and digits"},
"new_root_password": {"新的 root 密码:\n%[1]v", "New root password:\n%[1]v"},
"reinstall_done": {"重装完成,新的 root 密码:\n%[1]v", "Reinstall complete. Root password:\n%[1]v"},
"snapshot_shared": {"分享码已生成(已移除 %[1]v 个更早的检查点)", "share code created (%[1]v older checkpoint(s) removed)"},
"snapshot_unshared": {"分享已撤销", "share revoked"},
"snapshot_restored": {"已从该检查点恢复", "restored from that checkpoint"},
"err_account_expired": {"账户已到期,机器已锁定,请联系管理员延期。", "Account expired — the machine is locked. Contact the administrator to extend it."},
"ssh_keys_apply_warn": {"机器当前不可达,密钥已保存,将在下次重装后写入。", "Machine unreachable — keys saved and will be applied on the next reinstall."},
"err_bad_login": {"用户名或密码错误", "invalid credentials"},
"err_too_many": {"尝试过于频繁,请 1 分钟后再试", "Too many attempts, please wait 1 minute"},
"err_pass_mismatch": {"两次输入的密码不一致", "The two passwords do not match"},
"err_pass_short": {"密码长度不得低于 10 位", "password must be at least 10 characters"},
"err_pass_weak": {"密码需同时包含大写字母、小写字母和数字", "password must contain uppercase letters, lowercase letters and digits"},
"new_root_password": {"新的 root 密码:\n%[1]v", "New root password:\n%[1]v"},
"reinstall_done": {"重装完成,新的 root 密码:\n%[1]v", "Reinstall complete. Root password:\n%[1]v"},
"snapshot_shared": {"分享码已生成", "share code created"},
"snapshot_shared_removed": {"分享码已生成,已删除 %[1]v 个更早的检查点", "share code created — %[1]v older checkpoint(s) deleted"},
"snapshot_unshared": {"分享已撤销", "share revoked"},
"snapshot_restored": {"已从该检查点恢复", "restored from that checkpoint"},
"err_account_expired": {"账户已到期,机器已锁定,请联系管理员延期。", "Account expired — the machine is locked. Contact the administrator to extend it."},
"ssh_keys_apply_warn": {"机器当前不可达,密钥已保存,将在下次重装后写入。", "Machine unreachable — keys saved and will be applied on the next reinstall."},
}
pair, ok := m[key]
if !ok {
+15 -15
View File
@@ -290,7 +290,7 @@ footer a{color:var(--muted);text-decoration:underline}
<div class="grid">
<div class="card">
<h2>{{if eq .Lang "zh"}}机器管理{{else}}Machine{{end}}{{if eq .State "Running"}}<span class="pill up" style="margin-left:.5rem">{{if eq .Lang "zh"}}运行中{{else}}Running{{end}}</span>{{else}}<span class="pill down" style="margin-left:.5rem">{{.State}}</span>{{end}}</h2>
{{if .Expired}}<div class="expbanner">⚠ {{if eq .Lang "zh"}}账户已到期,机器已强制关机并锁定;除管理员延期或删除外的一切操作均被禁用。{{else}}Account expired: the container is force-stopped and locked. Everything is disabled except an admin extending or deleting it.{{end}}</div>{{else if .ExpiresSoon}}<div class="groupbanner">⚠ {{if eq .Lang "zh"}}配额即将到期,请及时延期。{{else}}Quota expiring soon — extend it in time.{{end}}</div>{{end}}
{{if .Expired}}<div class="expbanner">⚠ {{if eq .Lang "zh"}}账户已到期,机器已关机并锁定。{{else}}Account expired: the container is stopped and locked.{{end}}</div>{{else if .ExpiresSoon}}<div class="groupbanner">⚠ {{if eq .Lang "zh"}}配额即将到期,请及时延期。{{else}}Quota expiring soon — extend it in time.{{end}}</div>{{end}}
<div class="qgrid">
<div class="qitem"><span class="qlabel">CPU</span><span class="qval">{{.QuotaCPU}}{{if .CPULimited}} <span class="pill down" id="cpuLimitPill" data-until="{{.CPULimitUntil}}" title="{{if eq .Lang "zh"}}受管理员 CPU 动态限制规则限制,到期自动恢复原配额{{else}}capped by the admin's dynamic CPU limit rule; the normal quota returns when the countdown ends{{end}}">{{if eq .Lang "zh"}}限制中{{else}}limited{{end}} <span id="cpuLimitCount"></span></span>{{end}}</span></div>
<div class="qitem"><span class="qlabel">{{if eq .Lang "zh"}}内存{{else}}Memory{{end}}</span><span class="qval">{{.QuotaMem}}</span></div>
@@ -626,10 +626,10 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}}</textarea>
<div id="reDesc"></div>
{{if .ShareEnabled}}
<div class="field" style="margin-top:.6rem">
<label class="muted" style="font-size:.82rem">{{if eq .Lang "zh"}}或:从分享的检查点安装(填入分享码;填了则忽略上面的系统选择){{else}}Or: install from a shared checkpoint (enter a code; the system choice above is then ignored){{end}}</label>
<label class="muted" style="font-size:.82rem">{{if eq .Lang "zh"}}或从分享的检查点安装{{else}}Or install from a shared checkpoint{{end}}</label>
<input name="share" id="reShare" type="text" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" autocomplete="off" spellcheck="false" style="width:100%">
</div>
<div class="re-note" id="reHint" hidden>{{if eq .Lang "zh"}}已填入分享码:将从该检查点恢复,不安装上面的系统(上方系统选项已取消选中)。{{else}}Share code entered: the container will be rebuilt from that checkpoint, not from the system above (the system tiles are now unselected).{{end}}</div>
<div class="re-note" id="reHint" hidden>{{if eq .Lang "zh"}}将从该检查点恢复,不安装系统。{{else}}Rebuilding from that checkpoint; no system is installed.{{end}}</div>
{{end}}
<div class="actions">
<button class="btn" type="button" id="reCancel">{{if eq .Lang "zh"}}取消{{else}}Cancel{{end}}</button>
@@ -1065,8 +1065,8 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}}</textarea>
};
window.restoreSnap=function(n){
ask(T('恢复检查点','Restore checkpoint'),
T('恢复到检查点 '+n+' 将丢失当前数据以及此后的所有检查点,确认?',
'Restoring to checkpoint '+n+' loses the current data and all later checkpoints. Confirm?'),
T('恢复到 '+n+' 会丢失当前数据和此后的检查点。',
'Restoring to '+n+' loses the current data and every later checkpoint.'),
T('恢复','Restore')).then(function(ok){if(ok)submitSnap('/snapshot-restore',{name:n});});
};
// Publish a checkpoint. Publishing also DELETES the older checkpoints (a clone
@@ -1078,24 +1078,24 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}}</textarea>
var t=el.getAttribute('data-ts');
if(t&&ts&&t<ts) older++;
});
var m1=T('分享会把该检查点(包含容器内的全部数据与凭据)暴露给持有分享码的人。',
'Sharing exposes this checkpoint (including everything inside the container, credentials included) to whoever holds the code.');
var m1=T('拿到分享码的人可以读取该检查点内的全部数据,包括容器里的凭据。',
'Whoever holds the code can read everything in this checkpoint, including credentials inside the container.');
if(older>0){
m1+='\n'+T('一旦有人据此克隆,更早的 '+older+' 个检查点将永久无法再恢复,现在就会被删除。',
'Once someone clones it, the '+older+' older checkpoint(s) can never be restored to and will be DELETED now.');
m1+='\n'+T('分享后会删除更早的 '+older+' 个检查点。',
'Sharing also deletes the '+older+' older checkpoint(s).');
}
ask(T('分享检查点','Share checkpoint'),m1,T('继续','Continue')).then(function(ok){
if(!ok)return false;
if(older<=0)return true;
return ask(T('再次确认','Confirm again'),
T('这是第二步确认:更早的 '+older+' 个检查点现在会被删除且无法恢复,确定继续分享吗?',
'Second confirmation: the '+older+' older checkpoint(s) will be deleted now and cannot be recovered. Share anyway?'),
T('确定分享并删除','Share and delete'));
return ask(T('确认删除','Confirm deletion'),
T('更早的 '+older+' 个检查点会被删除,无法恢复。',
'The '+older+' older checkpoint(s) will be deleted and cannot be recovered.'),
T('分享并删除','Share and delete'));
}).then(function(ok){if(ok)submitSnap('/snapshot-share',{name:n});});
};
window.unshareSnap=function(){
ask(T('撤销分享','Revoke share'),
T('撤销分享?已据此安装的容器不受影响。','Revoke the share? Containers already installed from it are unaffected.'),
T('已据此安装的容器不受影响。','Containers already installed from it are unaffected.'),
T('撤销','Revoke')).then(function(ok){if(ok)submitSnap('/snapshot-unshare',{});});
};
// Create form and all destructive ops: keep the hash so the modal reopens
@@ -1918,7 +1918,7 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}}</textarea>
});
document.getElementById('notesForgotBtn').addEventListener('click',function(){
ask(T('重置便利贴','Reset sticky notes'),
T('是否重置所有便利贴内容?(重置后无法恢复)','Reset all sticky notes? This cannot be undone.'),
T('所有便利贴内容会被清空。','Every sticky note will be cleared.'),
T('重置','Reset')).then(function(ok){
if(!ok)return;
fetch(P+'/notes/reset',{method:'POST',credentials:'same-origin'}).then(function(r){return r.json();}).then(function(d){