From 9b81d8d31b368e46682c939ad212de94ec804852 Mon Sep 17 00:00:00 2001 From: Pama-Lee Date: Mon, 6 Jul 2026 23:47:30 +0800 Subject: [PATCH 1/2] fix(studio): eliminate 3 data-loss / auth P0s - 401 on the hand-rolled fetches (draft save, AI chat stream, server metrics) bypassed the global unauthorized handler, so an expired token during a save just surfaced "HTTP 401" and left the session stuck. Route all fetch paths through a shared noteUnauthorized() so any 401 on an authenticated call bounces to login with a redirect back. - Switching projects kept the previous project's open tabs, so a save wrote project A's ruleset into project B (silent cross-project corruption) and the editor's auto-open never fired. selectProject now resets the per-project tab/draft state on a project change, and the project picker warns before discarding unsaved edits. - Deep links, bookmarks, and hard reloads ignored the URL's org/project and loaded the last-saved workspace instead (wrong sidebar, blank editor). A router guard now hydrates the org/project stores from the URL before the target view mounts. --- .../apps/studio/src/api/platform-client.ts | 21 +++++++-- .../src/components/layout/AppLayout.vue | 21 ++++++++- .../apps/studio/src/i18n/locales/en.ts | 3 ++ .../apps/studio/src/i18n/locales/zh-CN.ts | 2 + .../apps/studio/src/i18n/locales/zh-TW.ts | 2 + ordo-editor/apps/studio/src/router/index.ts | 46 +++++++++++++++++++ ordo-editor/apps/studio/src/stores/project.ts | 13 ++++++ 7 files changed, 101 insertions(+), 7 deletions(-) diff --git a/ordo-editor/apps/studio/src/api/platform-client.ts b/ordo-editor/apps/studio/src/api/platform-client.ts index d4bb00da..9eb73b32 100644 --- a/ordo-editor/apps/studio/src/api/platform-client.ts +++ b/ordo-editor/apps/studio/src/api/platform-client.ts @@ -115,6 +115,18 @@ export function setUnauthorizedHandler(fn: () => void): void { onUnauthorized = fn; } +/** + * Fire the session-expired handler when an *authenticated* call is rejected as + * unauthorized (the token expired or was revoked). A 401 without a token is a + * bad-credentials login attempt, not an expired session, so it's ignored here. + * Every fetch path — the shared `request()` and the hand-rolled streaming/draft + * fetches — must funnel 401s through this, or an expired session leaves the UI + * stuck instead of bouncing to login. + */ +function noteUnauthorized(status: number, token?: string): void { + if (status === 401 && token) onUnauthorized?.(); +} + async function request( path: string, options: RequestInit & { token?: string } = {} @@ -131,11 +143,7 @@ async function request( const resp = await fetch(`${BASE}${path}`, { ...init, headers }); if (!resp.ok) { - // An authenticated call rejected as unauthorized ⇒ the session is gone. - // Not the login/register path (those send no token and 401 = bad creds). - if (resp.status === 401 && token) { - onUnauthorized?.(); - } + noteUnauthorized(resp.status, token); let errMsg = `HTTP ${resp.status}`; let errCode: string | undefined; try { @@ -169,6 +177,7 @@ async function requestText( const resp = await fetch(`${BASE}${path}`, { ...init, headers }); if (!resp.ok) { + noteUnauthorized(resp.status, token); let errMsg = `HTTP ${resp.status}`; let errCode: string | undefined; try { @@ -216,6 +225,7 @@ export const aiApi = { signal, }); if (!resp.ok || !resp.body) { + noteUnauthorized(resp.status, token); let m = `HTTP ${resp.status}`; try { m = (await resp.json()).error || m; @@ -806,6 +816,7 @@ export const rulesetDraftApi = { return resp.json() as Promise; } if (!resp.ok) { + noteUnauthorized(resp.status, token); let errMsg = `HTTP ${resp.status}`; let errCode: string | undefined; try { diff --git a/ordo-editor/apps/studio/src/components/layout/AppLayout.vue b/ordo-editor/apps/studio/src/components/layout/AppLayout.vue index 68419188..f6dc105b 100644 --- a/ordo-editor/apps/studio/src/components/layout/AppLayout.vue +++ b/ordo-editor/apps/studio/src/components/layout/AppLayout.vue @@ -2,7 +2,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { useI18n } from 'vue-i18n'; -import { MessagePlugin } from 'tdesign-vue-next'; +import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'; import platformLogo from '@/assets/platform-logo.png'; import { useAuthStore } from '@/stores/auth'; import { useOrgStore } from '@/stores/org'; @@ -90,7 +90,24 @@ const projectOptions = computed(() => function switchProject(projectId: string) { projectPickerOpen.value = false; - navigate(`/orgs/${currentOrgId.value}/projects/${projectId}/editor`); + if (projectId === currentProjectId.value) return; + const target = `/orgs/${currentOrgId.value}/projects/${projectId}/editor`; + // Switching projects clears the current project's open tabs (see project + // store's selectProject), so warn before discarding any unsaved edits. + if (projectStore.openTabs.some((tab) => tab.dirty)) { + const dlg = DialogPlugin.confirm({ + header: t('editor.closeConfirm'), + body: t('shell.switchProjectDirtyBody'), + confirmBtn: { content: t('shell.switchProjectDiscard'), theme: 'danger' }, + cancelBtn: t('common.cancel'), + onConfirm: () => { + dlg.hide(); + navigate(target); + }, + }); + return; + } + navigate(target); } // ── Notifications ──────────────────────────────────────────────────────────── diff --git a/ordo-editor/apps/studio/src/i18n/locales/en.ts b/ordo-editor/apps/studio/src/i18n/locales/en.ts index dbebaedd..d20e4705 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/en.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/en.ts @@ -124,6 +124,9 @@ export default { commandTitle: 'Search and Jump', noResults: 'No matching results', switchProject: 'Switch Project', + switchProjectDirtyBody: + 'You have unsaved changes in this project. Switching projects will discard them. Continue?', + switchProjectDiscard: 'Discard & switch', noProject: 'No project', defaultEngine: 'Default Engine', defaultEngineHint: 'No engine bound — using platform routing.', diff --git a/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts b/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts index d377c4cf..8e7aabd5 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts @@ -120,6 +120,8 @@ export default { commandTitle: '搜索与跳转', noResults: '没有匹配结果', switchProject: '切换项目', + switchProjectDirtyBody: '当前项目有未保存的修改,切换项目会丢弃它们。确定继续吗?', + switchProjectDiscard: '丢弃并切换', noProject: '未选择项目', defaultEngine: '默认引擎', defaultEngineHint: '未绑定引擎,使用平台路由。', diff --git a/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts b/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts index 0b0c8cd2..2c86ca68 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts @@ -120,6 +120,8 @@ export default { commandTitle: '搜尋與跳轉', noResults: '沒有符合結果', switchProject: '切換專案', + switchProjectDirtyBody: '目前專案有未儲存的修改,切換專案會捨棄它們。確定繼續嗎?', + switchProjectDiscard: '捨棄並切換', noProject: '未選擇專案', defaultEngine: '預設引擎', defaultEngineHint: '未綁定引擎,使用平台路由。', diff --git a/ordo-editor/apps/studio/src/router/index.ts b/ordo-editor/apps/studio/src/router/index.ts index c5f3ed3f..1b1999aa 100644 --- a/ordo-editor/apps/studio/src/router/index.ts +++ b/ordo-editor/apps/studio/src/router/index.ts @@ -1,5 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import { useAuthStore } from '@/stores/auth'; +import { useOrgStore } from '@/stores/org'; +import { useProjectStore } from '@/stores/project'; const router = createRouter({ history: createWebHistory(), @@ -255,4 +257,48 @@ router.beforeEach((to) => { } }); +// Hydrate the org/project stores from the URL before the target view mounts, so +// a deep link, a bookmark, or a hard reload lands on the workspace named in the +// path — not on the last-saved org/project. This must run in a guard (not in the +// layouts' onMounted) because Vue mounts child views before the parent shell's +// bootstrap fetch completes, so a project view would otherwise mount with empty +// stores and no way to recover. No-ops (and stays synchronous) when the stores +// already match the URL, so intra-project navigation pays nothing. +router.beforeEach(async (to) => { + const auth = useAuthStore(); + if (!auth.isLoggedIn) return; + + const orgId = typeof to.params.orgId === 'string' ? to.params.orgId : null; + const projectId = typeof to.params.projectId === 'string' ? to.params.projectId : null; + + if (orgId) { + const orgStore = useOrgStore(); + if (orgStore.currentOrg?.id !== orgId) { + // selectOrg resolves any accessible org directly (including sub-orgs), so + // this is correct even when the org isn't in the cached switcher list. + try { + await orgStore.selectOrg(orgId); + } catch { + // Inaccessible/removed org — let the view render its own error/empty + // state rather than blocking navigation. + } + } + } + + if (orgId && projectId) { + const projectStore = useProjectStore(); + if (projectStore.currentProject?.id !== projectId) { + try { + if (!projectStore.projects.some((p) => p.id === projectId)) { + await projectStore.fetchProjects(orgId); + } + const project = projectStore.projects.find((p) => p.id === projectId); + if (project) await projectStore.selectProject(project); + } catch { + // Same as above: don't trap the user on a hard failure here. + } + } + } +}); + export default router; diff --git a/ordo-editor/apps/studio/src/stores/project.ts b/ordo-editor/apps/studio/src/stores/project.ts index 796c9c6e..ed15de12 100644 --- a/ordo-editor/apps/studio/src/stores/project.ts +++ b/ordo-editor/apps/studio/src/stores/project.ts @@ -102,8 +102,21 @@ export const useProjectStore = defineStore('project', () => { } async function selectProject(project: Project) { + const changed = currentProject.value?.id !== project.id; currentProject.value = project; localStorage.setItem(CURRENT_PROJECT_KEY, project.id); + if (changed) { + // Switching to a different project: drop the previous project's open tabs, + // drafts, and metadata. Otherwise those tabs linger across the switch and a + // save writes the old project's ruleset into the new one (silent + // cross-project corruption), and the editor's "auto-open first ruleset" + // guard (openTabs.length === 0) never fires for the new project. + openTabs.value = []; + activeTabName.value = null; + draftMetas.value = []; + rulesets.value = []; + rulesetsError.value = null; + } await fetchRulesets(); } From eb2ec6aece0fa6756f7e9de304c11ffd380abafe Mon Sep 17 00:00:00 2001 From: Pama-Lee Date: Tue, 7 Jul 2026 00:15:29 +0800 Subject: [PATCH 2/2] fix(studio): repair 4 misleading/empty UI states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Analytics: a failed load rendered as "No execution data yet", identical to a genuinely idle project. Add a distinct error state (message + retry) that reads the store's error instead of falling through to the empty card. - Notification bell: the badge counted server-backed notifications (release reviews/approvals) plus session toasts, but the dropdown only listed the toasts — so a real notification lit the badge yet opened to "no notifications". The dropdown now fetches and merges both lists on open, and "mark all read" clears both. - Notifications inbox: read the org from the URL (not just the current store) so a deep link loads the right inbox, and surface load failures as an error state with retry instead of a swallowed empty list. The persistent-notifications store now propagates fetch errors. - Login: remove the dead "Forgot password?" link (no such route existed; it just reloaded the login page) and its now-unused styles. --- .../src/components/layout/AppLayout.vue | 113 +++++++++++++++--- .../apps/studio/src/i18n/locales/en.ts | 3 + .../apps/studio/src/i18n/locales/zh-CN.ts | 3 + .../apps/studio/src/i18n/locales/zh-TW.ts | 3 + .../src/stores/persistentNotifications.ts | 21 ++-- .../apps/studio/src/views/auth/LoginView.vue | 24 +--- .../views/notifications/NotificationsView.vue | 28 ++++- .../src/views/project/AnalyticsView.vue | 14 ++- 8 files changed, 151 insertions(+), 58 deletions(-) diff --git a/ordo-editor/apps/studio/src/components/layout/AppLayout.vue b/ordo-editor/apps/studio/src/components/layout/AppLayout.vue index f6dc105b..5157e356 100644 --- a/ordo-editor/apps/studio/src/components/layout/AppLayout.vue +++ b/ordo-editor/apps/studio/src/components/layout/AppLayout.vue @@ -127,6 +127,83 @@ function notifIcon(type: string) { return 'info-circle'; } +// ── Bell dropdown: merge server-backed inbox + transient toasts ─────────────── +// The badge counts persistent notifications (release reviews/approvals, polled +// as a count) plus transient session toasts. The dropdown must therefore render +// both — otherwise a release-review notification lights the badge but the +// dropdown opens to "no notifications". + +function persistentNotifTitle(type: string, payload: Record) { + const title = (payload.title as string) ?? ''; + if (type === 'release_review_requested') + return t('notifications.types.releaseReviewRequested', { title }); + if (type === 'release_approved') return t('notifications.types.releaseApproved', { title }); + if (type === 'release_rejected') return t('notifications.types.releaseRejected', { title }); + return type; +} + +function persistentNotifIcon(type: string) { + if (type === 'release_approved') return 'check-circle'; + if (type === 'release_rejected') return 'close-circle'; + if (type === 'release_review_requested') return 'notification'; + return 'info-circle'; +} + +interface BellItem { + id: string; + icon: string; + iconType: string; + title: string; + message?: string; + time: Date; + read: boolean; + onClick?: () => void; +} + +const bellItems = computed(() => { + const persistent: BellItem[] = persistentNotifStore.notifications.map((n) => ({ + id: `p:${n.id}`, + icon: persistentNotifIcon(n.type), + iconType: + n.type === 'release_approved' ? 'success' : n.type === 'release_rejected' ? 'error' : 'info', + title: persistentNotifTitle(n.type, n.payload), + time: new Date(n.created_at), + read: !!n.read_at, + onClick: () => { + const pid = n.payload.project_id as string | undefined; + if (!n.ref_id || !pid || !currentOrgId.value) return; + notifOpen.value = false; + if (!n.read_at) void persistentNotifStore.markRead(currentOrgId.value, n.id); + navigate(`/orgs/${currentOrgId.value}/projects/${pid}/releases`); + }, + })); + const transient: BellItem[] = notifStore.notifications.map((n) => ({ + id: `t:${n.id}`, + icon: notifIcon(n.type), + iconType: n.type, + title: n.title, + message: n.message, + time: n.timestamp, + read: n.read, + })); + return [...persistent, ...transient].sort((a, b) => b.time.getTime() - a.time.getTime()); +}); + +const bellUnread = computed(() => persistentNotifStore.unreadCount + notifStore.unreadCount); + +function toggleNotif() { + notifOpen.value = !notifOpen.value; + // Populate the server-backed list on open (polling only keeps the count). + if (notifOpen.value && currentOrgId.value) { + void persistentNotifStore.fetchNotifications(currentOrgId.value, false).catch(() => {}); + } +} + +async function handleBellMarkAllRead() { + notifStore.markAllRead(); + if (currentOrgId.value) await persistentNotifStore.markAllRead(currentOrgId.value); +} + // ── Page info ──────────────────────────────────────────────────────────────── const pageInfo = computed(() => { @@ -617,20 +694,13 @@ async function handleCreateOrg() { class="topbar-pill topbar-pill--icon" :class="{ 'is-open': notifOpen, - 'has-unread': persistentNotifStore.unreadCount > 0 || notifStore.unreadCount > 0, + 'has-unread': bellUnread > 0, }" - @click="notifOpen = !notifOpen" + @click="toggleNotif()" > - - {{ - persistentNotifStore.unreadCount + notifStore.unreadCount > 99 - ? '99+' - : persistentNotifStore.unreadCount + notifStore.unreadCount - }} + + {{ bellUnread > 99 ? '99+' : bellUnread }} @@ -638,33 +708,34 @@ async function handleCreateOrg() {
{{ t('shell.notifications') }}
-
+
{{ t('shell.noNotifications') }}
- - + +
{{ n.title }}
{{ n.message }}
-
{{ formatNotifTime(n.timestamp) }}
+
{{ formatNotifTime(n.time) }}
@@ -1359,6 +1430,10 @@ async function handleCreateOrg() { background: var(--ordo-bg-selected); } +.notif-item--clickable { + cursor: pointer; +} + .notif-icon { width: 26px; height: 26px; diff --git a/ordo-editor/apps/studio/src/i18n/locales/en.ts b/ordo-editor/apps/studio/src/i18n/locales/en.ts index d20e4705..f8e232e3 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/en.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/en.ts @@ -338,6 +338,7 @@ export default { emptyTitle: 'No execution data yet', emptyHint: 'Once your connected engine runs traffic it will appear here. This needs the engine to reach the platform over NATS.', + errorTitle: "Couldn't load analytics", }, integrate: { title: 'Integrate', @@ -1215,6 +1216,8 @@ export default { title: 'Notifications', markAllRead: 'Mark all read', empty: 'No notifications', + error: 'Failed to load notifications', + retry: 'Retry', unreadOnly: 'Unread only', viewInbox: 'View inbox', types: { diff --git a/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts b/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts index 8e7aabd5..18318bcd 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts @@ -330,6 +330,7 @@ export default { ruleset: '规则集', emptyTitle: '暂无执行数据', emptyHint: '已连接的引擎开始有流量后,数据会显示在这里。需要引擎能通过 NATS 连到平台。', + errorTitle: '分析数据加载失败', }, integrate: { title: '接入', @@ -1182,6 +1183,8 @@ export default { title: '通知', markAllRead: '全部标为已读', empty: '暂无通知', + error: '通知加载失败', + retry: '重试', unreadOnly: '仅显示未读', viewInbox: '查看收件箱', types: { diff --git a/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts b/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts index 2c86ca68..bdcf1530 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts @@ -330,6 +330,7 @@ export default { ruleset: '規則集', emptyTitle: '暫無執行資料', emptyHint: '已連接的引擎開始有流量後,資料會顯示在這裡。需要引擎能透過 NATS 連到平台。', + errorTitle: '分析資料載入失敗', }, integrate: { title: '接入', @@ -1182,6 +1183,8 @@ export default { title: '通知', markAllRead: '全部標為已讀', empty: '暫無通知', + error: '通知載入失敗', + retry: '重試', unreadOnly: '僅顯示未讀', viewInbox: '查看收件箱', types: { diff --git a/ordo-editor/apps/studio/src/stores/persistentNotifications.ts b/ordo-editor/apps/studio/src/stores/persistentNotifications.ts index f092efca..93e00296 100644 --- a/ordo-editor/apps/studio/src/stores/persistentNotifications.ts +++ b/ordo-editor/apps/studio/src/stores/persistentNotifications.ts @@ -21,19 +21,18 @@ export const usePersistentNotificationStore = defineStore('persistentNotificatio } } + // Surfaces errors to the caller (throws) so a failed load can render an error + // state instead of silently looking like an empty inbox. Background callers + // that don't care (e.g. the bell peek) should catch and ignore. async function fetchNotifications(orgId: string, unreadOnly = false) { if (!auth.token || !orgId) return; - try { - notifications.value = await notificationApi.list(auth.token, orgId, { - unread_only: unreadOnly, - limit: 50, - }); - unreadCount.value = unreadOnly - ? notifications.value.length - : notifications.value.filter((n) => !n.read_at).length; - } catch { - // ignore - } + notifications.value = await notificationApi.list(auth.token, orgId, { + unread_only: unreadOnly, + limit: 50, + }); + unreadCount.value = unreadOnly + ? notifications.value.length + : notifications.value.filter((n) => !n.read_at).length; } async function markRead(orgId: string, notifId: string) { diff --git a/ordo-editor/apps/studio/src/views/auth/LoginView.vue b/ordo-editor/apps/studio/src/views/auth/LoginView.vue index e40ecdbe..88352757 100644 --- a/ordo-editor/apps/studio/src/views/auth/LoginView.vue +++ b/ordo-editor/apps/studio/src/views/auth/LoginView.vue @@ -113,12 +113,7 @@ async function handleLogin() {
-
- - {{ - t('auth.forgotPassword') - }} -
+ import { computed, onMounted, ref } from 'vue'; -import { useRouter } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router'; import { useI18n } from 'vue-i18n'; import { usePersistentNotificationStore } from '@/stores/persistentNotifications'; import { useOrgStore } from '@/stores/org'; const { t } = useI18n(); +const route = useRoute(); const router = useRouter(); const orgStore = useOrgStore(); const store = usePersistentNotificationStore(); -const orgId = computed(() => orgStore.currentOrg?.id ?? ''); +// Prefer the URL's org so a deep link / hard reload loads the right inbox even +// before the shell has settled on a current org. +const orgId = computed( + () => (route.params.orgId as string | undefined) || orgStore.currentOrg?.id || '' +); const unreadOnly = ref(false); const loading = ref(false); +const error = ref(null); -onMounted(async () => { +async function load() { if (!orgId.value) return; loading.value = true; + error.value = null; try { await store.fetchNotifications(orgId.value, false); + } catch (e) { + // Surface the failure instead of rendering it as an empty inbox. + error.value = e instanceof Error ? e.message : t('notifications.error'); } finally { loading.value = false; } -}); +} + +onMounted(load); const filtered = computed(() => unreadOnly.value ? store.notifications.filter((n) => !n.read_at) : store.notifications @@ -97,6 +109,14 @@ function navigateToRelease(n: { ref_id?: string; payload: Record
+
+ +

{{ error }}

+ + {{ t('notifications.retry') }} + +
+

{{ t('notifications.empty') }}

diff --git a/ordo-editor/apps/studio/src/views/project/AnalyticsView.vue b/ordo-editor/apps/studio/src/views/project/AnalyticsView.vue index 635b517e..fc40d5ad 100644 --- a/ordo-editor/apps/studio/src/views/project/AnalyticsView.vue +++ b/ordo-editor/apps/studio/src/views/project/AnalyticsView.vue @@ -207,8 +207,17 @@ onBeforeUnmount(() => {
+ + +

{{ t('analytics.errorTitle') }}

+

{{ analytics.error }}

+ + {{ t('analytics.refresh') }} + +
+ - +

{{ t('analytics.emptyTitle') }}

{{ t('analytics.emptyHint') }}

@@ -323,6 +332,9 @@ onBeforeUnmount(() => { color: var(--td-text-color-secondary); margin: 0; } +.empty-action { + margin-top: 16px; +} @media (max-width: 900px) { .stat-row { grid-template-columns: repeat(2, 1fr);