From 06ecef33bc7e6bafc263b2dee71ffd290ab3dc42 Mon Sep 17 00:00:00 2001 From: johnnyjoygh Date: Sun, 19 Jul 2026 22:23:51 +0800 Subject: [PATCH] chore(web): update about page and access token settings --- scripts/release_smoke_test.sh | 359 ++++++++++++++++++ .../MemoView/components/MemoHeader.tsx | 3 +- web/src/components/RelativeTime.tsx | 13 + .../Settings/AccessTokenSection.tsx | 244 ++++++++---- web/src/lib/constants.ts | 3 + web/src/locales/en.json | 26 +- web/src/pages/About.tsx | 78 ++-- 7 files changed, 616 insertions(+), 110 deletions(-) create mode 100755 scripts/release_smoke_test.sh create mode 100644 web/src/components/RelativeTime.tsx diff --git a/scripts/release_smoke_test.sh b/scripts/release_smoke_test.sh new file mode 100755 index 00000000..b5891b23 --- /dev/null +++ b/scripts/release_smoke_test.sh @@ -0,0 +1,359 @@ +#!/usr/bin/env bash + +# Black-box smoke test for a Memos release image. +# +# By default, the script builds the current worktree as a local Docker image. +# Pass --candidate-image to test an image that has already been built. + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +candidate_image="${MEMOS_SMOKE_CANDIDATE_IMAGE:-}" +previous_image="${MEMOS_SMOKE_PREVIOUS_IMAGE:-}" +keep_resources="${MEMOS_SMOKE_KEEP_RESOURCES:-0}" + +usage() { + cat <<'EOF' +Usage: ./scripts/release_smoke_test.sh [options] + +Runs fresh-install and previous-stable upgrade smoke tests against a Memos +Docker image. With no options, the current worktree is built and tested. + +Options: + --candidate-image IMAGE Test an existing image instead of building locally. + --previous-image IMAGE Image used to seed the upgrade test. By default, the + latest stable Git tag before HEAD is used. + --keep-resources Keep containers and volumes after the test for debugging. + -h, --help Show this help text. + +Environment equivalents: + MEMOS_SMOKE_CANDIDATE_IMAGE + MEMOS_SMOKE_PREVIOUS_IMAGE + MEMOS_SMOKE_KEEP_RESOURCES=1 + +Examples: + ./scripts/release_smoke_test.sh + ./scripts/release_smoke_test.sh \ + --candidate-image memos-smoke:local \ + --previous-image neosmemo/memos:0.29.1 +EOF +} + +log() { + printf '\n==> %s\n' "$*" +} + +die() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +while (($# > 0)); do + case "$1" in + --candidate-image) + (($# >= 2)) || die "--candidate-image requires a value" + candidate_image="$2" + shift 2 + ;; + --previous-image) + (($# >= 2)) || die "--previous-image requires a value" + previous_image="$2" + shift 2 + ;; + --keep-resources) + keep_resources=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + die "unknown option: $1" + ;; + esac +done + +for command_name in curl docker git jq; do + command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required" +done + +docker info >/dev/null 2>&1 || die "Docker is not running" + +run_id="${GITHUB_RUN_ID:-local}-$(date +%s)-$$" +fresh_container="memos-smoke-fresh-$run_id" +upgrade_old_container="memos-smoke-upgrade-old-$run_id" +upgrade_new_container="memos-smoke-upgrade-new-$run_id" +fresh_volume="memos-smoke-fresh-$run_id" +upgrade_volume="memos-smoke-upgrade-$run_id" +temp_dir="$(mktemp -d)" +frontend_index="$REPO_ROOT/server/router/frontend/dist/index.html" +frontend_index_backup="$temp_dir/frontend-index.html" +frontend_index_existed=0 +frontend_index_backed_up=0 + +restore_frontend_index() { + if [[ "$frontend_index_backed_up" != "1" ]]; then + return + fi + + if [[ "$frontend_index_existed" == "1" ]]; then + cp "$frontend_index_backup" "$frontend_index" + else + rm -f "$frontend_index" + fi + frontend_index_backed_up=0 +} + +container_exists() { + docker container inspect "$1" >/dev/null 2>&1 +} + +cleanup() { + local status=$? + trap - EXIT + + restore_frontend_index + + if ((status != 0)); then + for container_name in "$fresh_container" "$upgrade_old_container" "$upgrade_new_container"; do + if container_exists "$container_name"; then + printf '\n--- docker logs: %s ---\n' "$container_name" >&2 + docker logs "$container_name" >&2 || true + fi + done + fi + + if [[ "$keep_resources" == "1" ]]; then + printf '\nKept smoke-test resources for debugging:\n' + printf ' containers: %s %s %s\n' "$fresh_container" "$upgrade_old_container" "$upgrade_new_container" + printf ' volumes: %s %s\n' "$fresh_volume" "$upgrade_volume" + else + docker rm -f "$fresh_container" "$upgrade_old_container" "$upgrade_new_container" >/dev/null 2>&1 || true + docker volume rm "$fresh_volume" "$upgrade_volume" >/dev/null 2>&1 || true + fi + + rm -rf "$temp_dir" + exit "$status" +} +trap cleanup EXIT + +detect_previous_image() { + local head_sha tag tag_sha + head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)" + + while IFS= read -r tag; do + case "$tag" in + *-rc.*) continue ;; + esac + + tag_sha="$(git -C "$REPO_ROOT" rev-list -n 1 "$tag")" + if [[ "$tag_sha" == "$head_sha" ]]; then + continue + fi + if git -C "$REPO_ROOT" merge-base --is-ancestor "$tag" HEAD; then + printf 'neosmemo/memos:%s\n' "${tag#v}" + return + fi + done < <(git -C "$REPO_ROOT" tag --list 'v[0-9]*' --sort=-version:refname) + + die "could not detect a previous stable release; pass --previous-image" +} + +build_local_candidate() { + local commit_sha image_tag + command -v pnpm >/dev/null 2>&1 || die "pnpm is required to build the local candidate" + + commit_sha="$(git -C "$REPO_ROOT" rev-parse --short=12 HEAD)" + image_tag="memos-smoke:${commit_sha}-${run_id}" + + mkdir -p "$(dirname "$frontend_index")" + if [[ -f "$frontend_index" ]]; then + cp "$frontend_index" "$frontend_index_backup" + frontend_index_existed=1 + fi + frontend_index_backed_up=1 + + log "Building frontend release assets" + ( + cd "$REPO_ROOT/web" + pnpm release + ) + + log "Building candidate image $image_tag" + docker build \ + --file "$REPO_ROOT/scripts/Dockerfile" \ + --build-arg VERSION=smoke-local \ + --build-arg COMMIT="$commit_sha" \ + --tag "$image_tag" \ + "$REPO_ROOT" + + restore_frontend_index + candidate_image="$image_tag" +} + +ensure_image() { + local image="$1" + if docker image inspect "$image" >/dev/null 2>&1; then + return + fi + log "Pulling image $image" + docker pull "$image" +} + +wait_until_ready() { + local container_name="$1" + local base_url="$2" + local attempt + + for attempt in $(seq 1 90); do + if curl --fail --silent --show-error --max-time 2 "$base_url/healthz" >/dev/null 2>&1; then + return + fi + if [[ "$(docker inspect --format '{{.State.Running}}' "$container_name" 2>/dev/null || true)" != "true" ]]; then + break + fi + sleep 2 + done + + docker logs "$container_name" >&2 || true + die "$container_name did not become ready" +} + +current_base_url="" + +set_current_base_url() { + local container_name="$1" + local port_mapping port + + port_mapping="$(docker port "$container_name" 5230/tcp)" + port="${port_mapping##*:}" + [[ -n "$port" ]] || die "could not determine the host port for $container_name" + current_base_url="http://127.0.0.1:$port" +} + +start_container() { + local container_name="$1" + local image="$2" + local volume="$3" + + docker run --detach \ + --name "$container_name" \ + --label "org.usememos.release-smoke=$run_id" \ + --publish "127.0.0.1::5230" \ + --env MEMOS_MODE=prod \ + --env MEMOS_INSTANCE_URL=http://localhost \ + --mount "type=volume,source=$volume,target=/var/opt/memos" \ + "$image" >/dev/null + + set_current_base_url "$container_name" + wait_until_ready "$container_name" "$current_base_url" +} + +assert_frontend_assets() { + local html asset_path asset_url + html="$(curl --fail --silent --show-error "$current_base_url/")" + grep -q 'id="root"' <<<"$html" || die "frontend root element was not served" + + asset_path="$(grep -o 'src="[^"]*\.js"' <<<"$html" | sed -n '1{s/^src="//;s/"$//;p;}' || true)" + [[ -n "$asset_path" ]] || die "frontend JavaScript asset was not found" + case "$asset_path" in + http://*|https://*) asset_url="$asset_path" ;; + /*) asset_url="$current_base_url$asset_path" ;; + *) asset_url="$current_base_url/$asset_path" ;; + esac + curl --fail --silent --show-error "$asset_url" >/dev/null +} + +create_admin() { + local payload response + payload="$(jq -nc '{username:"smoke-admin",password:"smoke-password",email:"smoke@example.test"}')" + response="$(curl --fail --silent --show-error \ + --header 'Content-Type: application/json' \ + --data "$payload" \ + "$current_base_url/api/v1/users")" + jq -e '.username == "smoke-admin" and .role == "ADMIN"' <<<"$response" >/dev/null +} + +sign_in() { + local payload + payload="$(jq -nc '{passwordCredentials:{username:"smoke-admin",password:"smoke-password"}}')" + curl --fail --silent --show-error \ + --header 'Content-Type: application/json' \ + --data "$payload" \ + "$current_base_url/api/v1/auth/signin" | jq -er '.accessToken' +} + +create_memo() { + local token="$1" + local memo_id="$2" + local content="$3" + local payload response + payload="$(jq -nc --arg content "$content" '{content:$content,visibility:"PRIVATE"}')" + response="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $token" \ + --header 'Content-Type: application/json' \ + --data "$payload" \ + "$current_base_url/api/v1/memos?memoId=$memo_id")" + jq -e --arg name "memos/$memo_id" --arg content "$content" '.name == $name and .content == $content' <<<"$response" >/dev/null +} + +assert_memo() { + local token="$1" + local memo_id="$2" + local content="$3" + curl --fail --silent --show-error \ + --header "Authorization: Bearer $token" \ + "$current_base_url/api/v1/memos/$memo_id" | + jq -e --arg content "$content" '.content == $content' >/dev/null +} + +if [[ -z "$candidate_image" ]]; then + build_local_candidate +else + ensure_image "$candidate_image" +fi + +if [[ -z "$previous_image" ]]; then + previous_image="$(detect_previous_image)" +fi +ensure_image "$previous_image" +[[ "$candidate_image" != "$previous_image" ]] || die "candidate and previous images must be different" + +log "Candidate image: $candidate_image" +log "Previous image: $previous_image" + +log "Running fresh-install smoke test" +docker volume create "$fresh_volume" >/dev/null +start_container "$fresh_container" "$candidate_image" "$fresh_volume" +assert_frontend_assets +create_admin +fresh_token="$(sign_in)" +create_memo "$fresh_token" "release-smoke" "fresh install smoke sentinel" + +docker restart "$fresh_container" >/dev/null +set_current_base_url "$fresh_container" +wait_until_ready "$fresh_container" "$current_base_url" +fresh_token="$(sign_in)" +assert_memo "$fresh_token" "release-smoke" "fresh install smoke sentinel" +docker rm -f "$fresh_container" >/dev/null + +log "Running $previous_image to $candidate_image upgrade smoke test" +docker volume create "$upgrade_volume" >/dev/null +start_container "$upgrade_old_container" "$previous_image" "$upgrade_volume" +create_admin +upgrade_token="$(sign_in)" +create_memo "$upgrade_token" "pre-upgrade-smoke" "created before release upgrade" +docker rm -f "$upgrade_old_container" >/dev/null + +start_container "$upgrade_new_container" "$candidate_image" "$upgrade_volume" +assert_frontend_assets +upgrade_token="$(sign_in)" +assert_memo "$upgrade_token" "pre-upgrade-smoke" "created before release upgrade" +create_memo "$upgrade_token" "post-upgrade-smoke" "created after release upgrade" +assert_memo "$upgrade_token" "post-upgrade-smoke" "created after release upgrade" + +log "Release smoke tests passed" diff --git a/web/src/components/MemoView/components/MemoHeader.tsx b/web/src/components/MemoView/components/MemoHeader.tsx index 1f9b8706..c1f84ce3 100644 --- a/web/src/components/MemoView/components/MemoHeader.tsx +++ b/web/src/components/MemoView/components/MemoHeader.tsx @@ -1,6 +1,7 @@ import { BookmarkIcon } from "lucide-react"; import { useCallback, useState } from "react"; import { Link } from "react-router-dom"; +import RelativeTime from "@/components/RelativeTime"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useNewMemo } from "@/contexts/NewMemoContext"; import useNavigateTo from "@/hooks/useNavigateTo"; @@ -36,7 +37,7 @@ const MemoHeader: React.FC = ({ showCreator, showVisibility, sh const timeValue = isArchived ? ( memoDisplayTime?.toLocaleString(i18n.language) ) : ( - + ); const displayTime = isDisplayingUpdatedTime ? ( <> diff --git a/web/src/components/RelativeTime.tsx b/web/src/components/RelativeTime.tsx new file mode 100644 index 00000000..f372e017 --- /dev/null +++ b/web/src/components/RelativeTime.tsx @@ -0,0 +1,13 @@ +import type { Format } from "@github/relative-time-element"; +import i18n from "@/i18n"; + +interface RelativeTimeProps { + date?: Date; + format?: Format; +} + +const RelativeTime = ({ date, format }: RelativeTimeProps) => ( + +); + +export default RelativeTime; diff --git a/web/src/components/Settings/AccessTokenSection.tsx b/web/src/components/Settings/AccessTokenSection.tsx index 3b0537dc..53a6587c 100644 --- a/web/src/components/Settings/AccessTokenSection.tsx +++ b/web/src/components/Settings/AccessTokenSection.tsx @@ -1,20 +1,22 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; import copy from "copy-to-clipboard"; -import { CopyIcon, ExternalLinkIcon, PlusIcon, TrashIcon } from "lucide-react"; +import { ChevronRightIcon, CopyIcon, ExternalLinkIcon, KeyRoundIcon, PlusIcon, ScissorsIcon, Trash2Icon } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "react-hot-toast"; import ConfirmDialog from "@/components/ConfirmDialog"; +import RelativeTime from "@/components/RelativeTime"; import { Button } from "@/components/ui/button"; import { userServiceClient } from "@/connect"; import useCurrentUser from "@/hooks/useCurrentUser"; import { useDialog } from "@/hooks/useDialog"; +import { WEB_CLIPPER_URL } from "@/lib/constants"; import { handleError } from "@/lib/error"; import { CreatePersonalAccessTokenResponse, PersonalAccessToken } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; import CreateAccessTokenDialog from "../CreateAccessTokenDialog"; -import SettingGroup from "./SettingGroup"; import SettingSection from "./SettingSection"; -import SettingTable from "./SettingTable"; + +const EXPIRING_SOON_MS = 30 * 24 * 60 * 60 * 1000; const ApiUsageExample = () => { const t = useTranslate(); @@ -26,7 +28,7 @@ const ApiUsageExample = () => { }; return ( -
+
         {example}
       
@@ -37,6 +39,24 @@ const ApiUsageExample = () => { ); }; +type TokenStatus = "active" | "expiring" | "idle"; + +const getTokenStatus = (lastUsedAt: Date | undefined, expiresAt: Date | undefined): TokenStatus => { + if (expiresAt && expiresAt.getTime() - Date.now() < EXPIRING_SOON_MS) { + return "expiring"; + } + return lastUsedAt ? "active" : "idle"; +}; + +const StatusDot = ({ status }: { status: TokenStatus }) => ( + ; }; +const SectionLabel = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + const About = () => { const t = useTranslate(); const { profile, generalSetting } = useInstance(); @@ -36,7 +41,7 @@ const About = () => { const instanceLogo = customProfile?.logoUrl || DEFAULT_LOGO; const isCustomBranded = instanceTitle !== DEFAULT_TITLE; - // Dev builds report version "dev" and commit "unknown"; show the raw version and skip the commit chip. + // Dev builds report version "dev" and commit "unknown"; show the raw version and skip the commit row. const hasSemver = isSemver(profile.version); const releaseUrl = hasSemver ? `${GITHUB_RELEASE_URL_PREFIX}${profile.version}` : ""; const versionLabel = hasSemver ? `v${profile.version}` : profile.version; @@ -44,47 +49,70 @@ const About = () => { const commitUrl = hasCommitSha ? `${GITHUB_COMMIT_URL_PREFIX}${profile.commit}` : ""; const shortCommit = hasCommitSha ? profile.commit.slice(0, 7) : ""; + const buildRows: { label: string; value: React.ReactNode }[] = []; + if (profile.version) { + buildRows.push({ label: t("common.version"), value: {versionLabel} }); + } + if (shortCommit) { + buildRows.push({ label: t("about.commit"), value: {shortCommit} }); + } + buildRows.push({ label: t("about.license"), value: MIT }); + if (isCustomBranded) { + buildRows.push({ + label: t("about.distribution"), + value: {t("about.powered-by")}, + }); + } + const projectLinks = [ { label: t("about.official-website"), note: "the project homepage", href: "https://usememos.com/" }, { label: t("about.documents"), note: "deploy, configure, use", href: "https://usememos.com/docs" }, { label: "API Docs", note: "REST + gRPC reference", href: "https://usememos.com/docs/api" }, { label: t("about.github-repository"), note: "source, issues, releases", href: "https://github.com/usememos/memos" }, + { label: "Web Clipper", note: t("about.web-clipper-platforms"), href: WEB_CLIPPER_URL, icon: ScissorsIcon }, ]; return (
-
-
-
- -
-
-

{instanceTitle}

- {profile.demo && Demo} -
-

{instanceTagline}

-
- {profile.version && {versionLabel}} - {shortCommit && {shortCommit}} - {isCustomBranded && Powered by Memos} -
-
+
+
+ +
+

{instanceTitle}

+ {profile.demo && Demo}
+

{instanceTagline}

+
-
+

Free and open source under the MIT license.

);