diff --git a/go-backend/internal/http/handler/flow_policy.go b/go-backend/internal/http/handler/flow_policy.go index c7a218d..460ee57 100644 --- a/go-backend/internal/http/handler/flow_policy.go +++ b/go-backend/internal/http/handler/flow_policy.go @@ -157,6 +157,12 @@ func (h *Handler) processPeerShareFlowFromForward(forwardID int64, serviceName s if h == nil || h.repo == nil || forwardID <= 0 { return } + + delta := item.D + item.U + if delta <= 0 { + return + } + forward, err := h.getForwardRecord(forwardID) if err != nil || forward == nil { // Forward not found in local database - might be a federation port-forward @@ -164,26 +170,22 @@ func (h *Handler) processPeerShareFlowFromForward(forwardID int64, serviceName s h.processPeerShareFlowByServiceName(serviceName, item) return } - tunnel, err := h.getTunnelRecord(forward.TunnelID) - if err != nil || tunnel == nil { - return - } tunnelName, err := h.repo.GetTunnelName(forward.TunnelID) if err != nil { + h.processPeerShareFlowByServiceName(serviceName, item) return } shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName) if !ok { + h.processPeerShareFlowByServiceName(serviceName, item) return } - delta := item.D + item.U - if delta <= 0 { + if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil { + h.processPeerShareFlowByServiceName(serviceName, item) return } - _ = h.repo.AddPeerShareCurrentFlow(shareID, delta) - share, err := h.repo.GetPeerShare(shareID) if err != nil || share == nil { return @@ -404,6 +406,11 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem if err != nil { return } + minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli() + hasUnboundForwardPeerRuntime, err := h.repo.HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID, minUpdatedTime) + if err != nil { + hasUnboundForwardPeerRuntime = false + } runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames)) for _, serviceName := range runtimeServiceNames { serviceName = strings.TrimSpace(serviceName) @@ -432,6 +439,9 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem parts := strings.Split(name, "_") if len(parts) >= 3 { forwardID, err := strconv.ParseInt(parts[0], 10, 64) + if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime { + continue + } if err == nil && forwardID > 0 && !h.forwardExists(forwardID) { _, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name, parts[0] + "_" + parts[1] + "_" + parts[2], parts[0] + "_" + parts[1] + "_" + parts[2] + "_tcp", parts[0] + "_" + parts[1] + "_" + parts[2] + "_udp"}}, false, true) continue @@ -451,6 +461,9 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem continue } forwardID, err := strconv.ParseInt(parts[0], 10, 64) + if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime { + continue + } if err != nil || forwardID <= 0 || h.forwardExists(forwardID) { continue } diff --git a/go-backend/internal/http/handler/flow_policy_federation_test.go b/go-backend/internal/http/handler/flow_policy_federation_test.go index 0e38b4b..ce80980 100644 --- a/go-backend/internal/http/handler/flow_policy_federation_test.go +++ b/go-backend/internal/http/handler/flow_policy_federation_test.go @@ -177,6 +177,66 @@ func TestProcessFlowItemTracksPeerShareFlowByForwardServiceName(t *testing.T) { } } +func TestProcessFlowItemFallsBackToServiceNameWhenForwardIDCollidesAcrossPanels(t *testing.T) { + r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-collision.db")) + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + now := time.Now().UnixMilli() + if err := r.CreatePeerShare(&repo.PeerShare{ + Name: "collision-share", + NodeID: 1, + Token: "collision-token", + MaxBandwidth: 0, + CurrentFlow: 0, + PortRangeStart: 31400, + PortRangeEnd: 31410, + IsActive: 1, + CreatedTime: now, + UpdatedTime: now, + }); err != nil { + t.Fatalf("create peer share: %v", err) + } + share, err := r.GetPeerShareByToken("collision-token") + if err != nil || share == nil { + t.Fatalf("load peer share: %v", err) + } + + if err := r.DB().Exec(` + INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, share.ID, share.NodeID, "collision-r1", "collision-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31401, "", 1, 1, now, now).Error; err != nil { + t.Fatalf("insert peer_share_runtime: %v", err) + } + + if err := r.DB().Exec(` + INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(2, 'local-tunnel-with-colliding-forward-id', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0) + `, now, now).Error; err != nil { + t.Fatalf("insert local tunnel: %v", err) + } + + if err := r.DB().Exec(` + INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx) + VALUES(20, 1, 'local-user', 'local-f20', 2, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0) + `, now, now).Error; err != nil { + t.Fatalf("insert local forward: %v", err) + } + + h := &Handler{repo: r} + h.processFlowItem(flowItem{N: "20_2_10_tcp", U: 120, D: 80}) + + updatedShare, err := r.GetPeerShare(share.ID) + if err != nil || updatedShare == nil { + t.Fatalf("reload share: %v", err) + } + if updatedShare.CurrentFlow != 200 { + t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow) + } +} + func TestProcessFlowItemSkipsPeerShareFlowWhenServiceNameIsAmbiguous(t *testing.T) { r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-ambiguous.db")) if err != nil { @@ -299,3 +359,48 @@ func TestCleanOrphanedServicesSkipsFederationServicePrefix(t *testing.T) { h.cleanOrphanedServices(1, []namedConfigItem{{Name: "fed_svc_999_tcp"}}) } + +func TestCleanOrphanedServicesSkipsForwardPatternWhenNodeHasActivePeerShareForwardRuntime(t *testing.T) { + r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-forward-runtime-empty-service.db")) + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + now := time.Now().UnixMilli() + if err := r.CreatePeerShare(&repo.PeerShare{ + Name: "cleanup-forward-runtime-empty-service", + NodeID: 1, + Token: "cleanup-forward-runtime-empty-service-token", + MaxBandwidth: 0, + CurrentFlow: 0, + PortRangeStart: 31420, + PortRangeEnd: 31430, + IsActive: 1, + CreatedTime: now, + UpdatedTime: now, + }); err != nil { + t.Fatalf("create peer share: %v", err) + } + share, err := r.GetPeerShareByToken("cleanup-forward-runtime-empty-service-token") + if err != nil || share == nil { + t.Fatalf("load peer share: %v", err) + } + + if err := r.DB().Exec(` + INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, share.ID, share.NodeID, "cleanup-forward-empty-r1", "cleanup-forward-empty-rk1", "", "forward", "", "", "tcp", "fifo", 31421, "", 0, 1, now, now).Error; err != nil { + t.Fatalf("insert peer_share_runtime with empty service name: %v", err) + } + + h := &Handler{repo: r} + + defer func() { + if rec := recover(); rec != nil { + t.Fatalf("cleanOrphanedServices should skip forward-pattern services when active peer-share forward runtime exists; got panic: %v", rec) + } + }() + + h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}}) +} diff --git a/go-backend/internal/http/handler/jobs_test.go b/go-backend/internal/http/handler/jobs_test.go index aeef09b..98a2315 100644 --- a/go-backend/internal/http/handler/jobs_test.go +++ b/go-backend/internal/http/handler/jobs_test.go @@ -69,6 +69,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) { t.Fatalf("insert expired user: %v", err) } + if err := r.DB().Exec(` + INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) + VALUES(3, 'non_expiring_user', 'x', 1, 0, 100, 1000, 2000, 15, 1, ?, ?, 1) + `, nowMs, nowMs).Error; err != nil { + t.Fatalf("insert non-expiring user: %v", err) + } + if err := r.DB().Exec(` INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(1, 't1', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0) @@ -83,6 +90,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) { t.Fatalf("insert expired user_tunnel: %v", err) } + if err := r.DB().Exec(` + INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) + VALUES(11, 3, 1, NULL, 1, 1, 300, 400, 15, 0, 1) + `).Error; err != nil { + t.Fatalf("insert non-expiring user_tunnel: %v", err) + } + if err := r.DB().Exec(` INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx) VALUES(20, 2, 'expired_user', 'f1', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0) @@ -90,6 +104,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) { t.Fatalf("insert forward: %v", err) } + if err := r.DB().Exec(` + INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx) + VALUES(21, 3, 'non_expiring_user', 'f2', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 1) + `, nowMs, nowMs).Error; err != nil { + t.Fatalf("insert non-expiring forward: %v", err) + } + h.runResetAndExpiryJob(now) userIn, userOut, userStatus := mustQueryInt64Int64Int(t, r, `SELECT in_flow, out_flow, status FROM user WHERE id = 2`) @@ -106,4 +127,19 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) { if forwardStatus != 0 { t.Fatalf("expected forward status=0 after expiry handling, got %d", forwardStatus) } + + nonExpUserStatus := mustQueryInt(t, r, `SELECT status FROM user WHERE id = 3`) + if nonExpUserStatus != 1 { + t.Fatalf("expected non-expiring user to remain enabled, got status=%d", nonExpUserStatus) + } + + nonExpTunnelStatus := mustQueryInt(t, r, `SELECT status FROM user_tunnel WHERE id = 11`) + if nonExpTunnelStatus != 1 { + t.Fatalf("expected non-expiring user_tunnel to remain enabled, got status=%d", nonExpTunnelStatus) + } + + nonExpForwardStatus := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 21`) + if nonExpForwardStatus != 1 { + t.Fatalf("expected non-expiring forward to remain enabled, got status=%d", nonExpForwardStatus) + } } diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index e378bfb..0113be9 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -1304,6 +1304,20 @@ func (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID return names, nil } +func (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) { + if r == nil || r.db == nil { + return false, errors.New("repository not initialized") + } + var count int64 + err := r.db.Model(&model.PeerShareRuntime{}). + Where("node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)", nodeID, "forward", minUpdatedTime). + Count(&count).Error + if err != nil { + return false, err + } + return count > 0, nil +} + func (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") @@ -2327,7 +2341,7 @@ func (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) { } var ids []int64 err := r.db.Model(&model.User{}). - Where("role_id != 0 AND status = 1 AND exp_time IS NOT NULL AND exp_time < ?", nowMs). + Where("role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?", nowMs). Pluck("id", &ids).Error if err != nil { return nil, err @@ -2347,7 +2361,7 @@ func (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredU return nil, errors.New("repository not initialized") } var uts []model.UserTunnel - err := r.db.Where("status = 1 AND exp_time IS NOT NULL AND exp_time < ?", nowMs).Find(&uts).Error + err := r.db.Where("status = 1 AND exp_time > 0 AND exp_time < ?", nowMs).Find(&uts).Error if err != nil { return nil, err } diff --git a/vite-frontend/src/components/search-bar.tsx b/vite-frontend/src/components/search-bar.tsx index 2a333c6..3a8643e 100644 --- a/vite-frontend/src/components/search-bar.tsx +++ b/vite-frontend/src/components/search-bar.tsx @@ -55,7 +55,6 @@ export function SearchBar({ transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }} > ) { +function AlertTitle({ + className, + children, + ...props +}: React.ComponentProps<"h5">) { return (
+ > + {children} +
); } diff --git a/vite-frontend/src/components/ui/card.tsx b/vite-frontend/src/components/ui/card.tsx index 525a9b3..de46041 100644 --- a/vite-frontend/src/components/ui/card.tsx +++ b/vite-frontend/src/components/ui/card.tsx @@ -25,7 +25,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) { ); } -function CardTitle({ className, ...props }: React.ComponentProps<"h3">) { +function CardTitle({ + className, + children, + ...props +}: React.ComponentProps<"h3">) { return (

) { )} data-slot="card-title" {...props} - /> + > + {children} +

); } diff --git a/vite-frontend/src/components/version-footer.tsx b/vite-frontend/src/components/version-footer.tsx new file mode 100644 index 0000000..ed3abc7 --- /dev/null +++ b/vite-frontend/src/components/version-footer.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from "react"; + +import { siteConfig } from "@/config/site"; +import { + UPDATE_CHANNEL_CHANGED_EVENT, + type UpdateReleaseChannel, + getLatestVersionByChannel, + getUpdateReleaseChannel, + hasVersionUpdate, +} from "@/utils/version-update"; + +const FALLBACK_GITHUB_REPO = "https://github.com/Sagit-chu/flux-panel"; + +interface VersionFooterProps { + version: string; + containerClassName?: string; + versionClassName?: string; + poweredClassName?: string; + updateBadgeClassName?: string; +} + +export function VersionFooter({ + version, + containerClassName, + versionClassName, + poweredClassName, + updateBadgeClassName, +}: VersionFooterProps) { + const [channel, setChannel] = useState( + getUpdateReleaseChannel(), + ); + const [updateAvailable, setUpdateAvailable] = useState(false); + const [latestUpdateVersion, setLatestUpdateVersion] = useState( + null, + ); + + useEffect(() => { + const handleChannelChange = () => { + setChannel(getUpdateReleaseChannel()); + }; + + window.addEventListener(UPDATE_CHANNEL_CHANGED_EVENT, handleChannelChange); + window.addEventListener("storage", handleChannelChange); + + return () => { + window.removeEventListener( + UPDATE_CHANNEL_CHANGED_EVENT, + handleChannelChange, + ); + window.removeEventListener("storage", handleChannelChange); + }; + }, []); + + useEffect(() => { + let active = true; + + const checkUpdate = async () => { + const latestVersion = await getLatestVersionByChannel( + channel, + siteConfig.github_repo || FALLBACK_GITHUB_REPO, + ); + + if (!active) { + return; + } + + if (!latestVersion) { + setUpdateAvailable(false); + setLatestUpdateVersion(null); + + return; + } + + const hasUpdate = hasVersionUpdate(version, latestVersion); + + setUpdateAvailable(hasUpdate); + setLatestUpdateVersion(hasUpdate ? latestVersion : null); + }; + + void checkUpdate(); + + return () => { + active = false; + }; + }, [channel, version]); + + return ( +
+

+ v{version} + {updateAvailable && latestUpdateVersion && ( + + {latestUpdateVersion} + + )} +

+

+ Powered by{" "} + + FLVX + +

+
+ ); +} diff --git a/vite-frontend/src/layouts/admin.tsx b/vite-frontend/src/layouts/admin.tsx index d3792a9..85ca556 100644 --- a/vite-frontend/src/layouts/admin.tsx +++ b/vite-frontend/src/layouts/admin.tsx @@ -20,6 +20,7 @@ import { } from "@/shadcn-bridge/heroui/modal"; import { Input } from "@/shadcn-bridge/heroui/input"; import { Logo } from "@/components/icons"; +import { VersionFooter } from "@/components/version-footer"; import { updatePassword } from "@/api"; import { safeLogout } from "@/utils/logout"; import { siteConfig } from "@/config/site"; @@ -337,9 +338,6 @@ export default function AdminLayout({

{siteConfig.name}

-

- v{siteConfig.version} -

@@ -405,17 +403,12 @@ export default function AdminLayout({
-

- Powered by{" "} - - FLVX - -

+
{/* 桌面端折叠按钮 */} diff --git a/vite-frontend/src/pages/config.tsx b/vite-frontend/src/pages/config.tsx index 113f148..2be307a 100644 --- a/vite-frontend/src/pages/config.tsx +++ b/vite-frontend/src/pages/config.tsx @@ -33,6 +33,11 @@ import { clearConfigCache, updateSiteConfig, } from "@/config/site"; +import { + type UpdateReleaseChannel, + getUpdateReleaseChannel, + setUpdateReleaseChannel, +} from "@/utils/version-update"; // 简单的保存图标组件 const SaveIcon = ({ className }: { className?: string }) => ( @@ -182,6 +187,9 @@ export default function ConfigPage() { }); const [announcementLoading, setAnnouncementLoading] = useState(true); const [announcementSaving, setAnnouncementSaving] = useState(false); + const [updateChannel, setUpdateChannel] = useState( + getUpdateReleaseChannel(), + ); // 权限检查 useEffect(() => { @@ -267,6 +275,14 @@ export default function ConfigPage() { } }; + const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => { + setUpdateChannel(channel); + setUpdateReleaseChannel(channel); + toast.success( + `更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`, + ); + }; + const handleConfigChange = (key: string, value: string) => { const newConfigs = { ...configs, [key]: value }; @@ -643,6 +659,42 @@ export default function ConfigPage() { ); })} + + + +
+
+

+ 更新通道 +

+

+ 稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc + 的版本。 +

+
+ + +
diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index a4b7c06..e5aa696 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -3199,7 +3199,7 @@ export default function ForwardPage() {
- +

按用户筛选

-

- Powered by{" "} - - FLVX - -

-

- v{isWebView ? siteConfig.app_version : siteConfig.version} -

-
+ {/* 验证码弹层 */} {showCaptcha && siteKey && ( diff --git a/vite-frontend/src/pages/profile.tsx b/vite-frontend/src/pages/profile.tsx index 91f0e2f..9cec7dd 100644 --- a/vite-frontend/src/pages/profile.tsx +++ b/vite-frontend/src/pages/profile.tsx @@ -15,6 +15,7 @@ import { import { Input } from "@/shadcn-bridge/heroui/input"; import { isWebViewFunc } from "@/utils/panel"; import { siteConfig } from "@/config/site"; +import { VersionFooter } from "@/components/version-footer"; import { updatePassword } from "@/api"; import { safeLogout } from "@/utils/logout"; import { getAdminFlag, getSessionName } from "@/utils/session"; @@ -312,22 +313,15 @@ export default function ProfilePage() { -
-

- Powered by{" "} - - FLVX - -

-

- v{isWebViewFunc() ? siteConfig.app_version : siteConfig.version} -

-
+
{/* 修改密码弹窗 */} diff --git a/vite-frontend/src/pages/settings.tsx b/vite-frontend/src/pages/settings.tsx index 4fa4517..185702e 100644 --- a/vite-frontend/src/pages/settings.tsx +++ b/vite-frontend/src/pages/settings.tsx @@ -5,7 +5,13 @@ import toast from "react-hot-toast"; import { Input } from "@/shadcn-bridge/heroui/input"; import { Button } from "@/shadcn-bridge/heroui/button"; import { Card, CardBody } from "@/shadcn-bridge/heroui/card"; +import { Select, SelectItem } from "@/shadcn-bridge/heroui/select"; import { reinitializeBaseURL } from "@/api/network"; +import { + type UpdateReleaseChannel, + getUpdateReleaseChannel, + setUpdateReleaseChannel, +} from "@/utils/version-update"; import { getPanelAddresses, savePanelAddress, @@ -25,6 +31,9 @@ export const SettingsPage = () => { const [panelAddresses, setPanelAddresses] = useState([]); const [newName, setNewName] = useState(""); const [newAddress, setNewAddress] = useState(""); + const [updateChannel, setUpdateChannel] = useState( + getUpdateReleaseChannel(), + ); const setPanelAddressesFunc = (newAddress: PanelAddress[]) => { setPanelAddresses(newAddress); @@ -79,6 +88,14 @@ export const SettingsPage = () => { loadPanelAddresses(); }, []); + const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => { + setUpdateChannel(channel); + setUpdateReleaseChannel(channel); + toast.success( + `更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`, + ); + }; + return (
{/* 顶部导航 */} @@ -117,6 +134,36 @@ export const SettingsPage = () => { {/* 内容区域 */}
+ + +

+ 更新设置 +

+
+ +

+ 版本提示会根据该通道检查最新版本。 +

+
+
+
+ {/* 添加新地址 */} diff --git a/vite-frontend/src/pages/user.tsx b/vite-frontend/src/pages/user.tsx index a8bc175..63858eb 100644 --- a/vite-frontend/src/pages/user.tsx +++ b/vite-frontend/src/pages/user.tsx @@ -638,7 +638,6 @@ export default function UserPage() { ) : (
+ > + {children} + ); } diff --git a/vite-frontend/src/utils/version-update.ts b/vite-frontend/src/utils/version-update.ts new file mode 100644 index 0000000..9c9e08e --- /dev/null +++ b/vite-frontend/src/utils/version-update.ts @@ -0,0 +1,218 @@ +export type UpdateReleaseChannel = "stable" | "dev"; + +export const UPDATE_CHANNEL_STORAGE_KEY = "update-release-channel"; +export const UPDATE_CHANNEL_CHANGED_EVENT = "updateReleaseChannelChanged"; + +const CHANNEL_STABLE: UpdateReleaseChannel = "stable"; +const CHANNEL_DEV: UpdateReleaseChannel = "dev"; + +const stableVersionPattern = /^\d+(?:\.\d+)+$/; +const testKeywordPattern = /(alpha|beta|rc)/i; + +const VERSION_CACHE_TTL_MS = 10 * 60 * 1000; + +type ReleaseItem = { + tag_name?: string; + draft?: boolean; +}; + +type LatestVersionCacheEntry = { + value: string | null; + expiresAt: number; +}; + +const latestVersionCache: Record< + UpdateReleaseChannel, + LatestVersionCacheEntry +> = { + stable: { value: null, expiresAt: 0 }, + dev: { value: null, expiresAt: 0 }, +}; + +const normalizeChannel = ( + value: string | null | undefined, +): UpdateReleaseChannel => { + return value === CHANNEL_DEV ? CHANNEL_DEV : CHANNEL_STABLE; +}; + +export const getUpdateReleaseChannel = (): UpdateReleaseChannel => { + if (typeof window === "undefined") { + return CHANNEL_STABLE; + } + + return normalizeChannel(localStorage.getItem(UPDATE_CHANNEL_STORAGE_KEY)); +}; + +export const setUpdateReleaseChannel = ( + channel: UpdateReleaseChannel, +): void => { + if (typeof window === "undefined") { + return; + } + + localStorage.setItem(UPDATE_CHANNEL_STORAGE_KEY, normalizeChannel(channel)); + window.dispatchEvent(new Event(UPDATE_CHANNEL_CHANGED_EVENT)); +}; + +const normalizeTag = (tag: string): string => { + return tag.trim().replace(/^v/i, ""); +}; + +type ReleaseTagChannel = UpdateReleaseChannel | null; + +const releaseChannelFromTag = (tag: string): ReleaseTagChannel => { + const normalizedTag = normalizeTag(tag).toLowerCase(); + + if (!normalizedTag) { + return null; + } + + if (stableVersionPattern.test(normalizedTag)) { + return CHANNEL_STABLE; + } + + if (testKeywordPattern.test(normalizedTag)) { + return CHANNEL_DEV; + } + + return null; +}; + +type VersionParts = { + numbers: number[]; + stageRank: number; + stageNumber: number; +}; + +const parseVersionParts = (version: string): VersionParts => { + const normalized = normalizeTag(version).toLowerCase(); + const numberMatches = normalized.match(/\d+/g) || []; + const numbers = numberMatches.map((item) => Number.parseInt(item, 10)); + + let stageRank = 0; + + if (normalized.includes("rc")) { + stageRank = 3; + } else if (normalized.includes("beta")) { + stageRank = 2; + } else if (normalized.includes("alpha")) { + stageRank = 1; + } else if (stableVersionPattern.test(normalized)) { + stageRank = 4; + } + + const stageNumberMatch = normalized.match(/(?:alpha|beta|rc)[.-]?(\d+)/); + const stageNumber = stageNumberMatch + ? Number.parseInt(stageNumberMatch[1], 10) + : 0; + + return { + numbers, + stageRank, + stageNumber, + }; +}; + +export const compareVersions = (left: string, right: string): number => { + const a = parseVersionParts(left); + const b = parseVersionParts(right); + const maxLength = Math.max(a.numbers.length, b.numbers.length); + + for (let i = 0; i < maxLength; i += 1) { + const aValue = a.numbers[i] || 0; + const bValue = b.numbers[i] || 0; + + if (aValue !== bValue) { + return aValue - bValue; + } + } + + if (a.stageRank !== b.stageRank) { + return a.stageRank - b.stageRank; + } + + if (a.stageNumber !== b.stageNumber) { + return a.stageNumber - b.stageNumber; + } + + return 0; +}; + +const repoPathFromUrl = (repoUrl: string): string | null => { + try { + const parsed = new URL(repoUrl); + const segments = parsed.pathname + .replace(/\.git$/i, "") + .split("/") + .filter(Boolean); + + if (segments.length < 2) { + return null; + } + + return `${segments[0]}/${segments[1]}`; + } catch { + return null; + } +}; + +export const getLatestVersionByChannel = async ( + channel: UpdateReleaseChannel, + repoUrl: string, +): Promise => { + const normalizedChannel = normalizeChannel(channel); + const now = Date.now(); + const cached = latestVersionCache[normalizedChannel]; + + if (cached.value && cached.expiresAt > now) { + return cached.value; + } + + const repoPath = repoPathFromUrl(repoUrl); + + if (!repoPath) { + return null; + } + + const response = await fetch( + `https://api.github.com/repos/${repoPath}/releases?per_page=50`, + { + headers: { + Accept: "application/vnd.github+json", + }, + }, + ); + + if (!response.ok) { + return null; + } + + const releases = (await response.json()) as ReleaseItem[]; + const candidateTags = releases + .filter((release) => !release.draft && typeof release.tag_name === "string") + .map((release) => (release.tag_name || "").trim()) + .filter((tag) => releaseChannelFromTag(tag) === normalizedChannel); + + if (candidateTags.length === 0) { + return null; + } + + const latest = candidateTags.sort((a, b) => compareVersions(b, a))[0]; + + latestVersionCache[normalizedChannel] = { + value: latest, + expiresAt: now + VERSION_CACHE_TTL_MS, + }; + + return latest; +}; + +export const hasVersionUpdate = ( + currentVersion: string, + latestVersion: string, +): boolean => { + return ( + compareVersions(normalizeTag(currentVersion), normalizeTag(latestVersion)) < + 0 + ); +}; diff --git a/vite-frontend/src/vite-env.d.ts b/vite-frontend/src/vite-env.d.ts index e8cf24a..6a25d84 100644 --- a/vite-frontend/src/vite-env.d.ts +++ b/vite-frontend/src/vite-env.d.ts @@ -9,3 +9,11 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +declare module "virtual:pwa-register" { + export function registerSW(options?: { + immediate?: boolean; + onNeedRefresh?: () => void; + onOfflineReady?: () => void; + }): (reloadPage?: boolean) => Promise; +}