style(dashboard): move version footer out of card
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m30s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m37s

This commit is contained in:
agent
2026-04-09 15:23:16 +08:00
parent 10da51c203
commit 089d0b4b58
23 changed files with 2114 additions and 525 deletions

View File

@@ -6,6 +6,8 @@ use sqlx::PgPool;
use uuid::Uuid;
use crate::db;
use crate::error::AppError;
use crate::models::EntryWriteRow;
#[derive(Debug, serde::Serialize)]
pub struct RollbackResult {
@@ -17,11 +19,9 @@ pub struct RollbackResult {
}
/// Roll back entry `name` to `to_version` (or the most recent snapshot if None).
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
pub async fn run(
pool: &PgPool,
name: &str,
folder: Option<&str>,
entry_id: Uuid,
to_version: Option<i64>,
user_id: Option<Uuid>,
) -> Result<RollbackResult> {
@@ -36,88 +36,26 @@ pub async fn run(
metadata: Value,
}
// Disambiguate: find the unique entry_id for (name, folder).
// Query entries_history by entry_id once we know it; first resolve via name + optional folder.
let entry_id: Option<Uuid> = if let Some(uid) = user_id {
if let Some(f) = folder {
sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND folder = $2 AND user_id = $3 LIMIT 1",
)
.bind(name)
.bind(f)
.bind(uid)
.fetch_optional(pool)
.await?
} else {
let ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND user_id = $2",
)
.bind(name)
.bind(uid)
.fetch_all(pool)
.await?;
match ids.len() {
0 => None,
1 => Some(ids[0]),
_ => {
let folders: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT folder FROM entries_history \
WHERE name = $1 AND user_id = $2",
)
.bind(name)
.bind(uid)
.fetch_all(pool)
.await?;
anyhow::bail!(
"Ambiguous: entries named '{}' exist in folders: [{}]. \
Specify 'folder' to disambiguate.",
name,
folders.join(", ")
)
}
}
}
} else if let Some(f) = folder {
sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND folder = $2 AND user_id IS NULL LIMIT 1",
let live_entry: Option<EntryWriteRow> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL",
)
.bind(name)
.bind(f)
.bind(entry_id)
.bind(uid)
.fetch_optional(pool)
.await?
} else {
let ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND user_id IS NULL",
sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL",
)
.bind(name)
.fetch_all(pool)
.await?;
match ids.len() {
0 => None,
1 => Some(ids[0]),
_ => {
let folders: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT folder FROM entries_history \
WHERE name = $1 AND user_id IS NULL",
)
.bind(name)
.fetch_all(pool)
.await?;
anyhow::bail!(
"Ambiguous: entries named '{}' exist in folders: [{}]. \
Specify 'folder' to disambiguate.",
name,
folders.join(", ")
)
}
}
.bind(entry_id)
.fetch_optional(pool)
.await?
};
let entry_id = entry_id.ok_or_else(|| anyhow::anyhow!("No history found for '{}'", name))?;
let live_entry = live_entry.ok_or(AppError::NotFoundEntry)?;
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
sqlx::query_as(
@@ -142,8 +80,8 @@ pub async fn run(
let snap = snap.ok_or_else(|| {
anyhow::anyhow!(
"No history found for '{}'{}.",
name,
"No history found for entry '{}'{}.",
live_entry.name,
to_version
.map(|v| format!(" at version {}", v))
.unwrap_or_default()
@@ -155,21 +93,9 @@ pub async fn run(
let mut tx = pool.begin().await?;
#[derive(sqlx::FromRow)]
struct LiveEntry {
id: Uuid,
version: i64,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
tags: Vec<String>,
metadata: Value,
}
// Lock the live entry if it exists (matched by entry_id for precision).
let live: Option<LiveEntry> = sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata FROM entries \
WHERE id = $1 FOR UPDATE",
let live: Option<EntryWriteRow> = sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND deleted_at IS NULL FOR UPDATE",
)
.bind(entry_id)
.fetch_optional(&mut *tx)
@@ -192,7 +118,7 @@ pub async fn run(
user_id,
folder: &lr.folder,
entry_type: &lr.entry_type,
name,
name: &lr.name,
version: lr.version,
action: "rollback",
tags: &lr.tags,
@@ -237,11 +163,13 @@ pub async fn run(
}
sqlx::query(
"UPDATE entries SET folder = $1, type = $2, tags = $3, metadata = $4, version = version + 1, \
updated_at = NOW() WHERE id = $5",
"UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \
version = version + 1, updated_at = NOW() WHERE id = $7",
)
.bind(&snap.folder)
.bind(&snap.entry_type)
.bind(&live_entry.name)
.bind(&live_entry.notes)
.bind(&snap.tags)
.bind(&snap_metadata)
.bind(lr.id)
@@ -250,36 +178,7 @@ pub async fn run(
lr.id
} else {
if let Some(uid) = user_id {
sqlx::query_scalar(
"INSERT INTO entries \
(user_id, folder, type, name, notes, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, $4, '', $5, $6, $7, NOW()) RETURNING id",
)
.bind(uid)
.bind(&snap.folder)
.bind(&snap.entry_type)
.bind(name)
.bind(&snap.tags)
.bind(&snap_metadata)
.bind(snap.version)
.fetch_one(&mut *tx)
.await?
} else {
sqlx::query_scalar(
"INSERT INTO entries \
(folder, type, name, notes, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, '', $4, $5, $6, NOW()) RETURNING id",
)
.bind(&snap.folder)
.bind(&snap.entry_type)
.bind(name)
.bind(&snap.tags)
.bind(&snap_metadata)
.bind(snap.version)
.fetch_one(&mut *tx)
.await?
}
return Err(AppError::NotFoundEntry.into());
};
if let Some(secret_snapshot) = snap_secret_snapshot {
@@ -292,8 +191,9 @@ pub async fn run(
"rollback",
&snap.folder,
&snap.entry_type,
name,
&live_entry.name,
serde_json::json!({
"entry_id": entry_id,
"restored_version": snap.version,
"original_action": snap.action,
}),
@@ -303,7 +203,7 @@ pub async fn run(
tx.commit().await?;
Ok(RollbackResult {
name: name.to_string(),
name: live_entry.name,
folder: snap.folder,
entry_type: snap.entry_type,
restored_version: snap.version,