production-dashboard #1

Merged
iqbal merged 13 commits from production-dashboard into main 2026-10-27 14:58:53 +00:00
6 changed files with 31 additions and 7 deletions
Showing only changes of commit 42464d88b3 - Show all commits

View File

@@ -98,6 +98,12 @@ export async function waitReady(id: number, onProgress?: (d: EngineDocument) =>
} }
} }
// ── Workspace wallet ──
// The organization's credit balance: Express spends workspace credits, never personal ones.
export const workspaceCredits = async () =>
(await request<{ organization: { credit_balance: number } | null }>('/organizations/me')).organization?.credit_balance ?? null
// ── Compose (text content) ── // ── Compose (text content) ──
export interface ComposeSet { id: number; name: string } export interface ComposeSet { id: number; name: string }

View File

@@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useClerk, useOrganization, useUser } from '@clerk/react' import { useClerk, useOrganization, useUser } from '@clerk/react'
import { useQuery } from '@tanstack/react-query'
import { NavLink, Outlet, useLocation } from 'react-router-dom' import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n' import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n'
import { workspaceCredits } from '../api/engine'
import { Brand, Icon, type IconName } from './Icon' import { Brand, Icon, type IconName } from './Icon'
import { Toaster } from './Toast' import { Toaster } from './Toast'
@@ -57,6 +59,17 @@ function HelpDialog({ onClose }: { onClose: () => void }) {
) )
} }
// Polled because agents and schedules also spend credits outside this tab; main.tsx refreshes it after every mutation.
function CreditBalance() {
const credits = useQuery({ queryKey: ['credits'], queryFn: workspaceCredits, refetchInterval: 30_000 })
if (credits.data == null) return null
return (
<span className="credit-pill" title={t('layout.creditsTitle')}>
<Icon name="coin" />{t('common.credits', { n: credits.data.toLocaleString(locale()) })}
</span>
)
}
export function AppLayout() { export function AppLayout() {
useLang() useLang()
const [help, setHelp] = useState(false) const [help, setHelp] = useState(false)
@@ -66,7 +79,6 @@ export function AppLayout() {
const { pathname } = useLocation() const { pathname } = useLocation()
const current = NAV.find(n => pathname.startsWith(n.to)) const current = NAV.find(n => pathname.startsWith(n.to))
const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-' const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-'
const today = new Intl.DateTimeFormat(locale(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
return ( return (
<div className="app"> <div className="app">
@@ -101,7 +113,7 @@ export function AppLayout() {
<div className="breadcrumb">{t('layout.breadcrumb')} <span>/</span> <b>{current ? t(current.label) : ''}</b></div> <div className="breadcrumb">{t('layout.breadcrumb')} <span>/</span> <b>{current ? t(current.label) : ''}</b></div>
<div className="topbar-right"> <div className="topbar-right">
<span className="demo-indicator"><i></i> {t('layout.engine')}</span> <span className="demo-indicator"><i></i> {t('layout.engine')}</span>
<span>{today.format(new Date())}</span> <CreditBalance />
<LanguageSwitch /> <LanguageSwitch />
</div> </div>
</header> </header>

View File

@@ -22,6 +22,7 @@ const paths: Record<string, string> = {
video: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3z"/>', video: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3z"/>',
audio: '<path d="M4 13v-2a8 8 0 0 1 16 0v2"/><rect x="3" y="11" width="4" height="8" rx="2"/><rect x="17" y="11" width="4" height="8" rx="2"/>', audio: '<path d="M4 13v-2a8 8 0 0 1 16 0v2"/><rect x="3" y="11" width="4" height="8" rx="2"/><rect x="17" y="11" width="4" height="8" rx="2"/>',
scorm: '<path d="m12 3 9 5-9 5-9-5zM3 8v9l9 5 9-5V8M12 13v9"/>', scorm: '<path d="m12 3 9 5-9 5-9-5zM3 8v9l9 5 9-5V8M12 13v9"/>',
coin: '<circle cx="12" cy="12" r="9"/><path d="M15 9.5c-.5-1-1.6-1.5-3-1.5-1.7 0-3 .8-3 2s1.3 1.7 3 2 3 .8 3 2-1.3 2-3 2c-1.4 0-2.5-.5-3-1.5M12 6v2M12 16v2"/>',
brain: '<path d="M12 5c-3-4-8-1-7 3-4 2-3 7 0 8-1 4 5 6 7 2M12 5c3-4 8-1 7 3 4 2 3 7 0 8 1 4-5 6-7 2M12 5v15M7 9l3 2M17 9l-3 2M6 15l3-1M18 15l-3-1"/>', brain: '<path d="M12 5c-3-4-8-1-7 3-4 2-3 7 0 8-1 4 5 6 7 2M12 5c3-4 8-1 7 3 4 2 3 7 0 8 1 4-5 6-7 2M12 5v15M7 9l3 2M17 9l-3 2M6 15l3-1M18 15l-3-1"/>',
} }

View File

@@ -16,6 +16,7 @@ const en = {
'layout.breadcrumb': 'Workspace', 'layout.breadcrumb': 'Workspace',
'layout.signOut': 'Sign out', 'layout.signOut': 'Sign out',
'layout.engine': 'learning-lab engine', 'layout.engine': 'learning-lab engine',
'layout.creditsTitle': 'Workspace credit balance',
'layout.devStrip': 'Development phase: connected to the learning-lab engine. Review AI output before using it.', 'layout.devStrip': 'Development phase: connected to the learning-lab engine. Review AI output before using it.',
'layout.footer': 'A workspace for knowledge that keeps growing.', 'layout.footer': 'A workspace for knowledge that keeps growing.',
'layout.mainNav': 'Main navigation', 'layout.mainNav': 'Main navigation',
@@ -397,6 +398,7 @@ const id: Record<Key, string> = {
'layout.breadcrumb': 'Ruang kerja', 'layout.breadcrumb': 'Ruang kerja',
'layout.signOut': 'Keluar', 'layout.signOut': 'Keluar',
'layout.engine': 'Engine learning-lab', 'layout.engine': 'Engine learning-lab',
'layout.creditsTitle': 'Saldo kredit workspace',
'layout.devStrip': 'Fase development: terhubung ke engine learning-lab. Hasil AI perlu ditinjau sebelum dipakai.', 'layout.devStrip': 'Fase development: terhubung ke engine learning-lab. Hasil AI perlu ditinjau sebelum dipakai.',
'layout.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.', 'layout.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.',
'layout.mainNav': 'Navigasi utama', 'layout.mainNav': 'Navigasi utama',

View File

@@ -2,7 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { ClerkProvider } from '@clerk/react' import { ClerkProvider } from '@clerk/react'
import { enUS, idID } from '@clerk/localizations' import { enUS, idID } from '@clerk/localizations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import { EngineError } from './api/client' import { EngineError } from './api/client'
import App from './App' import App from './App'
@@ -16,7 +16,9 @@ if (!clerkPubKey) {
throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY (set it, or keep ../learning-lab/backend/.env available)') throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY (set it, or keep ../learning-lab/backend/.env available)')
} }
const queryClient = new QueryClient({ const queryClient: QueryClient = new QueryClient({
// Any action may have spent workspace credits, so the topbar balance is read again after each one.
mutationCache: new MutationCache({ onSettled: () => queryClient.invalidateQueries({ queryKey: ['credits'] }) }),
defaultOptions: { defaultOptions: {
// Retrying a 4xx (auth, credits, validation) only repeats the same answer. // Retrying a 4xx (auth, credits, validation) only repeats the same answer.
queries: { retry: (n, e) => n < 2 && !(e instanceof EngineError && e.status >= 400 && e.status < 500) }, queries: { retry: (n, e) => n < 2 && !(e instanceof EngineError && e.status >= 400 && e.status < 500) },

View File

@@ -79,7 +79,9 @@ input::placeholder,textarea::placeholder{color:#98a2b3}
.breadcrumb span{color:#d0d5dd} .breadcrumb span{color:#d0d5dd}
.breadcrumb b{color:var(--ink);font-weight:600} .breadcrumb b{color:var(--ink);font-weight:600}
.topbar-right{font-size:13px;color:var(--muted);gap:16px} .topbar-right{font-size:13px;color:var(--muted);gap:16px}
.demo-indicator{height:30px;padding:0 12px;border:1px solid var(--line);border-radius:999px;background:#fff;font-size:12.5px;font-weight:550;color:#344054} .demo-indicator{white-space:nowrap;height:30px;padding:0 12px;border:1px solid var(--line);border-radius:999px;background:#fff;font-size:12.5px;font-weight:550;color:#344054}
.credit-pill{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 12px;border-radius:999px;background:var(--light);color:var(--blue-dark);font-size:12.5px;font-weight:700;white-space:nowrap}
.credit-pill svg{width:15px;height:15px}
.demo-indicator i{width:7px;height:7px;background:#059669;box-shadow:0 0 0 3px #d1fae5} .demo-indicator i{width:7px;height:7px;background:#059669;box-shadow:0 0 0 3px #d1fae5}
.lang-switch{border:0;border-radius:999px;background:#f2f4f7;padding:3px} .lang-switch{border:0;border-radius:999px;background:#f2f4f7;padding:3px}
.lang-switch button{border-radius:999px;padding:5px 11px;font-size:12px;color:#475467} .lang-switch button{border-radius:999px;padding:5px 11px;font-size:12px;color:#475467}
@@ -227,8 +229,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.sidebar nav :is(button,a).active::before{display:none} .sidebar nav :is(button,a).active::before{display:none}
.workspace-main{margin:0;width:100%} .workspace-main{margin:0;width:100%}
.topbar{position:static;height:auto;min-height:56px;padding:10px 16px;flex-wrap:wrap} .topbar{position:static;height:auto;min-height:56px;padding:10px 16px;flex-wrap:wrap}
.topbar-right{gap:10px} .topbar-right{gap:8px 10px;flex-wrap:wrap}
.topbar-right>span:not(.demo-indicator){display:none}
.demo-strip{margin:16px 16px 0;border-radius:16px;font-size:12px} .demo-strip{margin:16px 16px 0;border-radius:16px;font-size:12px}
.app main{padding:22px 16px} .app main{padding:22px 16px}
.app-footer{margin-left:16px;margin-right:16px} .app-footer{margin-left:16px;margin-right:16px}