Câu chuyện về một đêm deploy — Release Planner *, *::before, *::after { box-sizing: border-box; } body { margin: 0; background: #f9f8f6; color: #1c1c1e; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 17px; line-height: 1.75; } /* ── Hero ── */ .hero { background: linear-gradient(135deg, #0f2027, #203a43, #2c5364); color: #fff; padding: 72px 24px 60px; text-align: center; } .hero .kicker { display: inline-block; background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.25); border-radius: 100px; font-size: 12px; letter-spacing: 1.5px; text-transform: uppercase; padding: 4px 14px; margin-bottom: 20px; } .hero h1 { font-size: clamp(1.8rem, 5vw, 3rem); font-weight: 800; margin: 0 0 16px; line-height: 1.2; color: #fff !important; } .hero .kicker { color: #fff !important; } .hero .sub { max-width: 560px; margin: 0 auto; color: rgba(255,255,255,0.75) !important; font-size: 1rem; } /* ── Page layout: sidebar + article ── */ .page-layout { display: grid; grid-template-columns: 210px minmax(0, 720px); max-width: 980px; margin: 0 auto; align-items: start; padding: 0 24px; } @media (max-width: 860px) { .page-layout { grid-template-columns: 1fr; padding: 0; } } /* ── TOC Sidebar ── */ .toc-sidebar { position: sticky; top: 32px; padding: 48px 20px 48px 0; align-self: start; } @media (max-width: 860px) { .toc-sidebar { display: none; } } .toc-label { font-size: 10px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: #999; margin-bottom: 12px; } .toc-list { list-style: none; padding: 0; margin: 0; } .toc-list li { margin-bottom: 2px; } .toc-list a { display: block; font-size: 0.8rem; color: #888; text-decoration: none; padding: 5px 10px 5px 12px; border-left: 2px solid transparent; border-radius: 0 4px 4px 0; transition: color 0.15s, border-color 0.15s, background 0.15s; line-height: 1.35; } .toc-list a:hover { color: #1c1c1e; background: rgba(0,0,0,0.04); } .toc-list a.active { color: #0077cc; border-left-color: #0077cc; font-weight: 600; background: rgba(0,119,204,0.06); } .toc-num { display: inline-block; font-size: 9px; font-weight: 700; letter-spacing: 0.5px; color: #bbb; margin-right: 4px; vertical-align: middle; } .toc-list a.active .toc-num { color: #0077cc; } /* ── Article layout ── */ article { padding: 48px 24px 80px; min-width: 0; } /* ── Chapter divider ── */ .chapter { display: flex; align-items: center; gap: 12px; margin: 56px 0 20px; } .chapter:first-child { margin-top: 0; } .chapter-num { background: #1c1c1e; color: #fff; font-size: 11px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; padding: 4px 10px; border-radius: 4px; white-space: nowrap; } .chapter-line { flex: 1; height: 1px; background: #e0ddd8; } .chapter h2 { font-size: 1.5rem; font-weight: 700; margin: 0; } h2.section-title { font-size: 1.5rem; font-weight: 700; margin: 0 0 20px; color: #1c1c1e; } h3 { font-size: 1.1rem; font-weight: 700; margin: 32px 0 8px; color: #1c1c1e; } p { margin: 0 0 1.1em; } /* ── Scene / pull-quote ── */ .scene { background: #fff; border-left: 4px solid #ff9500; border-radius: 0 8px 8px 0; padding: 20px 24px; margin: 28px 0; font-style: italic; color: #444; font-size: 1.05rem; } .callout { background: #fff3cd; border-left: 4px solid #f5a623; border-radius: 0 8px 8px 0; padding: 16px 20px; margin: 24px 0; font-size: 0.95rem; color: #5a4000; } .callout strong { color: #3a2800; } .insight { background: #eaf4ff; border-left: 4px solid #0077cc; border-radius: 0 8px 8px 0; padding: 16px 20px; margin: 24px 0; font-size: 0.95rem; color: #003a66; } /* ── Code block ── */ pre { background: #1e1e2e; color: #cdd6f4; border-radius: 8px; padding: 18px 20px; overflow-x: auto; font-size: 0.85rem; line-height: 1.6; margin: 20px 0; } code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; } p code, li code { background: #f0ede8; padding: 2px 6px; border-radius: 4px; font-size: 0.88em; color: #c0392b; } /* ── Image ── */ .story-img { width: 100%; border-radius: 10px; margin: 28px 0; box-shadow: 0 4px 24px rgba(0,0,0,0.12); } .img-caption { text-align: center; font-size: 0.82rem; color: #888; margin: -18px 0 28px; } /* ── Finding card ── */ .finding { background: #fff; border: 1px solid #e8e4de; border-radius: 10px; padding: 24px 24px 20px; margin: 28px 0; } .finding-tag { display: inline-block; font-size: 10px; font-weight: 700; letter-spacing: 1.2px; text-transform: uppercase; padding: 3px 10px; border-radius: 100px; margin-bottom: 10px; } .tag-critical { background: #fde8e8; color: #c0392b; } .tag-high { background: #fef3e2; color: #d35400; } .tag-medium { background: #e8f4fd; color: #1a6fa8; } .finding h4 { font-size: 1rem; font-weight: 700; margin: 0 0 10px; color: #1c1c1e; } .finding p { margin: 0 0 10px; font-size: 0.95rem; color: #444; } /* ── Gates grid ── */ .gates { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin: 24px 0; } .gate-card { background: #fff; border: 1px solid #e0ddd8; border-radius: 8px; padding: 14px 16px; } .gate-card .gate-name { font-weight: 700; font-size: 0.9rem; color: #0077cc; margin-bottom: 4px; } .gate-card .gate-q { font-size: 0.82rem; color: #555; line-height: 1.4; } /* ── Mode cards ── */ .mode-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 24px 0; } @media (max-width: 520px) { .mode-cards { grid-template-columns: 1fr; } } .mode-card { background: #fff; border: 1px solid #e0ddd8; border-radius: 10px; padding: 20px; } .mode-card .badge { display: inline-block; font-size: 11px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; padding: 4px 12px; border-radius: 100px; margin-bottom: 10px; } .badge-review { background: #fde8e8; color: #c0392b; } .badge-create { background: #e8f4e8; color: #1e7e34; } .mode-card h4 { margin: 0 0 8px; font-size: 1rem; } .mode-card p { margin: 0; font-size: 0.88rem; color: #555; } /* ── CTA / Start block ── */ .cta-block { background: linear-gradient(135deg, #0f2027, #2c5364); color: #fff; border-radius: 12px; padding: 36px 28px; margin: 48px 0 0; text-align: center; } .cta-block h2 { margin: 0 0 8px; font-size: 1.4rem; color: #fff !important; } .cta-block p { color: rgba(255,255,255,0.75) !important; margin: 0 0 20px; font-size: 0.95rem; } .cta-steps { text-align: left; background: rgba(255,255,255,0.08); border-radius: 8px; padding: 16px 20px; margin: 0 auto 20px; max-width: 480px; } .cta-steps p { color: rgba(255,255,255,0.85) !important; margin: 0 0 6px; font-size: 0.88rem; } .cta-steps p:last-child { margin: 0; } .cta-steps .step-label { display: inline-block; background: rgba(255,255,255,0.2); border-radius: 4px; font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; padding: 1px 7px; margin-right: 6px; } /* ── Time comparison ── */ .time-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 24px 0; } @media (max-width: 480px) { .time-compare { grid-template-columns: 1fr; } } .time-card { border-radius: 8px; padding: 16px 18px; font-size: 0.88rem; } .time-card .tc-label { font-weight: 700; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; } .time-card .tc-num { font-size: 1.4rem; font-weight: 800; margin-bottom: 2px; } .time-good { background: #e8f8f0; color: #1a6640; } .time-bad { background: #fde8e8; color: #7b1a1a; } ul, ol { padding-left: 1.4rem; margin: 0 0 1em; } li { margin-bottom: 4px; } strong { font-weight: 700; } em { font-style: italic; } hr.divider { border: none; border-top: 1px solid #e0ddd8; margin: 40px 0; } Release Planner · Câu chuyện thực tế Một seeder bị bỏ quênvà sự cố chúng tôi không muốn lặp lại Từ một sự cố production — đến ý tưởng dùng AI đọc diff thay con người — đến những vấn đề bất ngờ được phát hiện trên các dự án thực tế. Mục lục 01Sự cố seeder bị bỏ quên 02Không phải lỗi người 03Xây Release Planner 047 Release Gates 05Quy trình mới 06Cách sử dụng 07Một số vấn đề đã phát hiện thực tế Chương 1 Sáng deploy xong, chiều khách vào check màn hình. Đó là một ngày release bình thường. Dev hoàn thành deploy buổi sáng — pipeline xanh, báo khách, mọi thứ trông ổn. Release step đã được viết sẵn, không có gì bất thường: Step 1: Đồng bộ code main Step 2: Cherry-pick commit, tạo PR Step 3: Merge PR lên GitHub Step 4: Approve pipeline deploy Step 5: Verify code (kiểm tra 2 dòng đặc trưng) Step 6: Báo khách verify Buổi chiều, khách hàng vào màn hình để kiểm tra theo quy trình nghiệm thu của họ — và phát hiện lỗi. "Có một màn hình bị lỗi." Dev nhận tin, chuyển ngay sang infra: "Bạn check logs giúp mình, xem lỗi gì?" Infra vào xem logs — phát hiện lỗi do thiếu data trong một bảng master. Trong lần release đó, dev đã thêm một file seeder mới để insert master data vào database. Nhưng release step không có bước chạy seeder đó. Dữ liệu master bị thiếu — màn hình phụ thuộc vào bảng đó bắt đầu trả về lỗi. Bên dự án không có quyền truy cập vào app của khách. Không thể tự chạy seeder để fix ngay. Phải phối hợp với phía khách để được cấp quyền — mất thêm vài tiếng đồng hồ, trong khi màn hình đó vẫn bị lỗi với người dùng cuối. Chương 2 Không phải lỗi người — là lỗi quy trình Chúng tôi ngồi lại sau sự cố. Không ai muốn đổ lỗi cho ai — Thực tế, các vấn đề phát sinh chủ yếu xuất phát từ việc quy trình còn thiếu, hoặc đã có quy trình nhưng chưa được đào tạo và áp dụng thống nhất. Vì vậy, chất lượng của mỗi lần release vẫn đang phụ thuộc quá nhiều vào kinh nghiệm và know-how của từng cá nhân. Vấn đề là: không có ai — hoặc cái gì — được giao nhiệm vụ đọc kỹ code diff và đối chiếu với release step. Với team chỉ có dev và infra, không có người review độc lập, khâu đó bị bỏ qua một cách vô tình và có hệ thống. Team infra ngồi lại phân tích sự cố và đặt ra câu hỏi: liệu AI có thể làm việc này không — đọc diff, đối chiếu với release step, và báo lại nếu có gì không khớp theo một bộ tiêu chí chuẩn? Từ câu hỏi đó, ý tưởng được hiện thực hóa — và Release Step Reviewer ra đời. Dev và Infra ngồi lại sau sự cố — nhận ra quy trình cũ thiếu một khâu kiểm soát, và đặt câu hỏi: AI có thể đọc diff thay chúng ta không? Chương 3 Chúng tôi xây Release Planner — và bắt đầu áp dụng vào dự án thực Chúng tôi xây dựng một skill AI với 2 chế độ: REVIEW Review release plan có sẵn Nhận file release step + code diff → phân tích, chấm điểm theo 7 tiêu chí chuẩn, liệt kê vấn đề và đề xuất fix. CREATE Tạo release plan mới từ đầu AI hỏi về repo, loại thay đổi, môi trường → tự tạo release plan hoàn chỉnh với đầy đủ backup, migrate, deploy, verify, rollback. Để đảm bảo AI đánh giá đúng và nhất quán, chúng tôi cần một bộ tiêu chí chuẩn — không thay đổi theo người, không bỏ sót theo cảm tính. Từ đó, 7 Release Gates ra đời. REVIEW mode — AI đối chiếu release step với code diff, chấm điểm 7 Release Gates Chương 4 Tiêu chuẩn đánh giá: 7 Release Gates Sau nhiều lần review, chúng tôi đúc kết thành 7 tiêu chí — gọi là Release Gates. Mọi release plan đều được đánh giá theo 7 gate này. Chưa đảm bảo gate nào → BLOCKED, chưa được deploy. 7 Release Gates — mỗi gate là một câu hỏi phải trả lời được trước khi deploy Approval Đã có người nắm chuyên môn approve chưa? Impact Phạm vi ảnh hưởng đã phân tích đủ từ code diff chưa? Quality Verify plan đủ cụ thể, có expected result, có test data chưa? Sequence Backup đầy đủ trước mỗi bước thay đổi chưa? Rollback Có kịch bản rollback chạy được, có trigger rõ ràng chưa? Monitor Có ai monitor sau deploy, check gì, bao lâu, escalate thế nào? DOD Deployer và verifier có độc lập nhau không? Chương 5 Bây giờ quy trình của team trông như thế nào Chúng tôi không thay đổi nhiều — chỉ thêm một bước review AI trước khi deploy. Chi phí thực tế là khoảng 30–60 phút của dev để đọc báo cáo và fix release step. Đổi lại là không còn sự cố production bất ngờ. Quy trình mới: Dev viết release step → Infra bổ sung infra steps → AI review theo 7 Gates → Thực hiện release Với AI review 30–60 phút Fix release step trước deploy — không có incident Nếu sự cố production Nhiều giờ Điều tra, xin quyền truy cập, hotfix, thông báo khách Chương 6 Cách sử dụng — Release Planner trong thực tế Release Planner có 2 chế độ dùng độc lập nhau. Tùy tình huống mà chọn: CREATE Tạo release plan từ đầu Khi nào dùng: Dev chuẩn bị release nhưng chưa có release step nào. Mở AI, nhắn: Tạo release plan cho dự án X lên production. AI sẽ hỏi lần lượt để thu thập đủ thông tin: Repo nào? Branch nào? Deployment method (ECS, VM, CloudFront...)? Có migration / seed không? Có thay đổi ENV không? (list key, không cần value) Ai deploy, ai verify? Sau khi thu thập đủ → AI tạo release plan hoàn chỉnh với đầy đủ: backup, migrate, deploy, verify, monitor, rollback. Chuẩn bị sẵn trước khi bắt đầu: commit hash, tên repo, server type, danh sách ENV key mới (nếu có) — để AI không phải hỏi đi hỏi lại giữa chừng. CREATE mode — AI hỏi thông tin từng bước và tạo release plan hoàn chỉnh REVIEW Review release plan có sẵn Khi nào dùng: Dev đã có release step, cần review kỹ trước khi deploy lên production. Bước 1 — Chuẩn bị file diff: # Export code diff từ git (chạy trên từng repo) git diff main..release/yyyymmdd > repo-name.diff # Nếu nhiều repo, nén lại zip release.zip *.diff Bước 2 — Nhắn AI: Review file release plan này giúp tôi. [đính kèm file release step] [đính kèm release.zip] AI trả về trong 2–5 phút: Kết luận tổng: PASS / PASS WITH CONDITIONS / BLOCKED Bảng Gate Sign-off — 7 gates, gate nào chưa đạt sẽ được đánh dấu rõ Danh sách issue cụ thể kèm mức độ Critical / High / Medium Đề xuất fix từng issue — có thể dùng thẳng để cập nhật release step Nếu kết quả là BLOCKED: dev đọc danh sách issue, fix release step, rồi chạy lại review. Chỉ deploy khi kết quả là PASS hoặc PASS WITH CONDITIONS đã được xử lý. REVIEW mode — AI đối chiếu release step với code diff, xuất báo cáo theo 7 Release Gates Chương 7 Một số vấn đề đã phát hiện thực tế — mà chúng tôi đã bỏ qua Dưới đây là một số vấn đề đã phát hiện trong quá trình chạy release trên một số dự án trong thời gian qua. Tên dự án đã được ẩn. Critical Seeder đã bị xóa — release step vẫn gọi nó Diff xóa CategoryStoreSeeder.php, thay bằng CategoryStoreTwoSeeder.php. Release step vẫn gọi lệnh cũ: php artisan db:seed --class=CategoryStoreSeeder Class không còn tồn tại sau khi deploy code mới. Lệnh này throw ReflectionException ngay lập tức — release dừng giữa chừng. Nguyên nhân: dev copy release step từ lần release trước, không đọc lại diff để kiểm tra tên class còn tồn tại không. Critical Verify "OK" — nhưng data đang sai từ đầu Step 12 ghi "Kiểm tra 6 category mới". Nhưng seeder thực tế chỉ tạo 2 category con với tên hoàn toàn khác. Người verify chạy xong báo OK — vì họ tin vào release step hơn là tự kiểm tra. AI đọc diff, so sánh tên và số lượng record trong seeder với nội dung bước verify — phát hiện không khớp. Critical ENV mới trong code — release step không có dòng nào khai báo AI đọc diff và phát hiện 2 biến môi trường mới chưa từng xuất hiện trước đó: THIRD_PARTY_API_KEY = os.environ.get("NEW_THIRD_PARTY_API_KEY") WEBHOOK_SECRET = os.environ.get("NEW_WEBHOOK_SECRET") Release step không có bước khai báo 2 biến này vào Terraform / Secret Manager. Sau deploy, service vẫn start thành công (vì os.environ.get trả None thay vì crash) — nhưng mọi luồng gọi third-party API và xử lý webhook thất bại âm thầm. High Rollback trỏ về snapshot của tháng trước Bước backup tạo snapshot tên pre-release-20260623. Bước rollback lại restore từ pre-release-20260609 — tên của release tháng trước. Nếu rollback khẩn cấp, database sẽ về trạng thái cũ hơn dự định 2 tuần. High Rollback có kịch bản — nhưng không ai biết khi nào bấm Plan có bảng rollback đầy đủ. Nhưng không ghi: khi nào rollback? Ai quyết định? Tiêu chí là gì? Khi sự cố xảy ra, cả team nhìn nhau — không ai dám tự quyết. High Không có RDS snapshot trước migration Release có 3 migration mới + seed 41 bản ghi vào production. Không có bước tạo DB snapshot trước. Nếu data seed sai — không có điểm restore. Medium Step verify trỏ đến dòng code đã không còn tồn tại Step verify ghi routes/api.php dòng 64 với tên method cụ thể. Nhưng sau khi AI đối chiếu diff, method đó đã được đổi tên. Người thực hiện không tìm thấy → bỏ qua, báo "OK". Medium Không backup ENV trước deploy Backup DB có. Backup code có. Backup ENV — bị bỏ quên. Khi rollback code về version cũ nhưng ENV đã bị sửa, app cũ chạy với ENV mới — hành vi không thể đoán trước được. # AI đề xuất thêm bước này trước khi deploy: aws ecs describe-task-definition --task-definition <task-def-name> \ --query 'taskDefinition.containerDefinitions[].environment' \ > env_backup_before_$(date +%Y%m%d_%H%M%S).json Tất cả những vấn đề trên đều có một điểm chung: chúng không phải lỗi kỹ thuật — chúng là lỗi do không có ai đọc kỹ diff và đối chiếu với release step trước khi deploy. Release Planner làm đúng một việc đó, mỗi lần release. Bắt đầu ngay Workspace release-planner — đã có trên ☞ Kari Create Nhắn AI: "Tạo release plan cho dự án X lên production" Review Export diff → nhắn AI: "Review file release plan này giúp tôi" → đính kèm file + diff Export diff: git diff main..release/yyyymmdd > repo.diff (function () { const links = document.querySelectorAll('.toc-list a'); const sections = Array.from(links).map(a => document.querySelector(a.getAttribute('href'))); function setActive(index) { links.forEach((a, i) => a.classList.toggle('active', i === index)); } function onScroll() { const scrollY = window.scrollY + 120; let active = 0; sections.forEach((sec, i) => { if (sec && sec.offsetTop <= scrollY) active = i; }); setActive(active); } window.addEventListener('scroll', onScroll, { passive: true }); onScroll(); links.forEach(a => { a.addEventListener('click', function (e) { e.preventDefault(); const target = document.querySelector(this.getAttribute('href')); if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }); })(); (()=>{const __name=(target)=>target;(function runEngagementClient(config3) { const root = globalThis; const endpoint = "/api/analytics/engagement"; const sessionTimeout = 18e5; const idleTimeout = 6e4; const checkpointInterval = 3e4; const storageKey = `kshare.engagement.${config3.fileId}.${config3.version}`; const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function newSessionId() { if (root.crypto.randomUUID) return root.crypto.randomUUID(); const bytes = root.crypto.getRandomValues(new Uint8Array(16)); bytes[6] = (bytes[6] ?? 0) & 15 | 64; bytes[8] = (bytes[8] ?? 0) & 63 | 128; const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } __name(newSessionId, "newSessionId"); function restore() { try { const raw2 = root.sessionStorage.getItem(storageKey); if (!raw2) return null; const value = JSON.parse(raw2); if (typeof value.id !== "string" || !uuidV4.test(value.id) || typeof value.lastSeen !== "number" || Date.now() - value.lastSeen > sessionTimeout) { return null; } return { id: value.id, engagedMs: Number.isFinite(value.engagedMs) ? Math.max(0, value.engagedMs ?? 0) : 0, maxScroll: Number.isFinite(value.maxScroll) ? Math.max(0, value.maxScroll ?? 0) : 0, interactionCount: Number.isFinite(value.interactionCount) ? Math.max(0, value.interactionCount ?? 0) : 0, lastSeen: value.lastSeen }; } catch { return null; } } __name(restore, "restore"); const restored = restore(); const session = restored ?? { id: newSessionId(), engagedMs: 0, maxScroll: 0, interactionCount: 0, lastSeen: Date.now() }; let lastTick = Date.now(); let lastActivity = Date.now(); let lastInteractionAt = 0; let lastFlush = 0; function persist() { session.lastSeen = Date.now(); try { root.sessionStorage.setItem(storageKey, JSON.stringify(session)); } catch { } } __name(persist, "persist"); function tick() { const now = Date.now(); if (root.document.visibilityState === "visible") { const activeEnd = Math.min(now, lastActivity + idleTimeout); if (activeEnd > lastTick) session.engagedMs += activeEnd - lastTick; } lastTick = now; } __name(tick, "tick"); function updateScroll() { const doc = root.document.documentElement; const body = root.document.body; const height = Math.max( doc.scrollHeight, doc.offsetHeight, doc.clientHeight, body?.scrollHeight ?? 0, body?.offsetHeight ?? 0, root.window.innerHeight ); const depth = Math.max( 0, Math.min(100, Math.round((root.window.scrollY + root.window.innerHeight) / height * 100)) ); session.maxScroll = Math.max(session.maxScroll, depth); } __name(updateScroll, "updateScroll"); function markActivity() { tick(); const now = Date.now(); lastActivity = now; if (now - lastInteractionAt >= 1e3) { session.interactionCount += 1; lastInteractionAt = now; } updateScroll(); persist(); } __name(markActivity, "markActivity"); function flush() { tick(); updateScroll(); persist(); lastFlush = Date.now(); const body = JSON.stringify({ token: config3.token, session_id: session.id, engaged_ms: Math.round(session.engagedMs), max_scroll: Math.round(session.maxScroll), interaction_count: Math.round(session.interactionCount) }); let sent = false; try { sent = root.navigator.sendBeacon?.( endpoint, new root.Blob([body], { type: "application/json" }) ) ?? false; } catch { sent = false; } if (!sent) { void root.fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body, credentials: "same-origin", keepalive: true }).catch(() => void 0); } } __name(flush, "flush"); updateScroll(); root.document.addEventListener("pointerdown", markActivity, { passive: true }); root.document.addEventListener("touchstart", markActivity, { passive: true }); root.document.addEventListener("keydown", markActivity); root.window.addEventListener("scroll", markActivity, { passive: true }); root.document.addEventListener("visibilitychange", () => { if (root.document.visibilityState === "hidden") { flush(); } else { lastTick = Date.now(); lastActivity = Date.now(); } }); root.window.addEventListener("pagehide", flush); root.setInterval(() => { tick(); updateScroll(); if (Date.now() - lastFlush >= checkpointInterval) flush(); }, 1e3); flush(); })({"token":"eyJmaWxlSWQiOjM3MiwidmVyc2lvbiI6MiwiZXhwIjoxNzg1Mzg1Mjk5MjgyfQ.16a4a33906e940dac40c9e14c7bc7276c8f42e7bde28ba991c87378c741d9157","fileId":372,"version":2});})();[data-kshare-viewer-protection-notice]{position:fixed;right:16px;bottom:16px;z-index:2147483647;max-width:min(360px,calc(100vw - 32px));padding:10px 12px;border-radius:8px;background:#111827;color:#fff;font:14px/1.4 system-ui,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,.24)}[data-kshare-viewer-protection-notice][hidden]{display:none!important}[data-kshare-viewer-protection-print]{display:none!important}@media print{[data-kshare-viewer-protection-print]{display:block!important;position:fixed;right:8mm;bottom:6mm;z-index:2147483647;color:#6b7280;font:8pt/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.04em;pointer-events:none}}KS-FCCB-BB97-BC80-37BD-CBCD-F015(()=>{const __name=(target)=>target;(function runViewerProtection(config3) { const root = globalThis; const notice = root.document.querySelector("[data-kshare-viewer-protection-notice]"); function showNotice() { if (!notice) return; notice.textContent = config3.actionNotice; notice.hidden = false; root.setTimeout(() => { notice.hidden = true; }, 3e3); } __name(showNotice, "showNotice"); function block(event) { event.preventDefault(); showNotice(); } __name(block, "block"); root.document.addEventListener( "keydown", (event) => { const key3 = event.key?.toLowerCase(); if ((event.ctrlKey || event.metaKey) && key3 === "s") { block(event); } }, true ); })({"actionNotice":"Việc lưu nội dung được chia sẻ này đã bị hạn chế."});})();[data-kshare-local-redaction-code][hidden]{display:none!important}html[data-kshare-local-redacted],html[data-kshare-local-redacted] body{background:#fff!important}[data-kshare-local-redaction-code]:not([hidden]){position:fixed;right:12px;bottom:10px;color:#9ca3af;font:11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.04em}KS-FCCB-BB97-BC80-37BD-CBCD-F015(()=>{const __name=(target)=>target;(function runLocalRedaction() { const root = globalThis; if (root.location.protocol !== "file:") return; const marker = root.document.querySelector("[data-kshare-local-redaction-code]"); if (!marker || !root.document.body) return; root.document.documentElement.setAttribute("data-kshare-local-redacted", ""); root.document.body.replaceChildren(marker); marker.hidden = false; })();})();