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:
1 parent
3c48c27cc3
commit
c2009ddc69
9 files changed
+97
-110
No files matched your search
+38
-54
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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){
|
||||
|
||||
Reference in new issue
Block a user