('Container image')
fireEvent.change(image, { target: { value: '' } })
expect(image.value).toBe('')
- expect(screen.getByRole('button', { name: 'Submit job' }).hasAttribute('disabled')).toBe(true)
+ expect(
+ screen.getByRole('button', { name: 'Submit job' }).hasAttribute('disabled'),
+ ).toBe(true)
fireEvent.change(image, { target: { value: 'cpu' } })
expect(image.value).toBe('cpu')
fireEvent.change(image, { target: { value: 'cpu-runtime' } })
diff --git a/web-interface/src/components/Navbar.tsx b/web-interface/src/components/Navbar.tsx
index 6814752..3b50311 100644
--- a/web-interface/src/components/Navbar.tsx
+++ b/web-interface/src/components/Navbar.tsx
@@ -1,7 +1,14 @@
import { Link } from '@tanstack/react-router'
import { authClient, useAccount } from '#/auth.tsx'
import { useState } from 'react'
-import { LayoutDashboard, Play, Database, Server, Users } from 'lucide-react'
+import {
+ LayoutDashboard,
+ Play,
+ Database,
+ Server,
+ Users,
+ BookOpen,
+} from 'lucide-react'
import { WorkspaceSelector } from '#/teams.tsx'
export default function Navbar() {
@@ -60,6 +67,12 @@ export default function Navbar() {
{' '}
Teams
+
+
+
+ {' '}
+ Docs
+
diff --git a/web-interface/src/components/jobs/JobSubmissionForm.tsx b/web-interface/src/components/jobs/JobSubmissionForm.tsx
index 7bd51f3..21958ba 100644
--- a/web-interface/src/components/jobs/JobSubmissionForm.tsx
+++ b/web-interface/src/components/jobs/JobSubmissionForm.tsx
@@ -86,7 +86,7 @@ export function JobSubmissionForm({
const image =
(mode === 'training-smoke'
? profile?.default_image
- : imageOverride ?? profile?.default_image) || ''
+ : (imageOverride ?? profile?.default_image)) || ''
const images =
catalog?.images.filter((option) => option.accelerators.includes(compute)) ??
[]
diff --git a/web-interface/src/format.ts b/web-interface/src/format.ts
index e5e74c1..109eb12 100644
--- a/web-interface/src/format.ts
+++ b/web-interface/src/format.ts
@@ -1,6 +1,9 @@
export function formatBytes(bytes: number) {
if (bytes === 0) return '0 B'
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
- const power = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
- return `${(bytes / 1024 ** power).toLocaleString(undefined, {maximumFractionDigits: power ? 1 : 0})} ${units[power]}`
+ const power = Math.min(
+ Math.floor(Math.log(bytes) / Math.log(1024)),
+ units.length - 1,
+ )
+ return `${(bytes / 1024 ** power).toLocaleString(undefined, { maximumFractionDigits: power ? 1 : 0 })} ${units[power]}`
}
diff --git a/web-interface/src/routes/__root.tsx b/web-interface/src/routes/__root.tsx
index ccda042..6df912b 100644
--- a/web-interface/src/routes/__root.tsx
+++ b/web-interface/src/routes/__root.tsx
@@ -6,9 +6,16 @@ import '../styles.css'
import Navbar from '#/components/Navbar.tsx'
import { AccountGate } from '#/auth.tsx'
import { TeamGate } from '#/teams.tsx'
+import { BackendConnectionError } from '#/components/BackendConnectionError.tsx'
export const Route = createRootRoute({
component: RootComponent,
+ errorComponent: ({ error, reset }) => (
+
+ ),
})
function RootComponent() {
diff --git a/web-interface/vite.config.ts b/web-interface/vite.config.ts
index dc69775..a52cb6c 100644
--- a/web-interface/vite.config.ts
+++ b/web-interface/vite.config.ts
@@ -1,12 +1,130 @@
import { defineConfig } from 'vite'
+import type { Plugin } from 'vite'
import { devtools } from '@tanstack/devtools-vite'
import tsconfigPaths from 'vite-tsconfig-paths'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { execSync } from 'node:child_process'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+
+const MIME_TYPES: Record = {
+ '.html': 'text/html; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.js': 'application/javascript; charset=utf-8',
+ '.mjs': 'application/javascript; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.xml': 'application/xml; charset=utf-8',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.webp': 'image/webp',
+ '.ico': 'image/x-icon',
+ '.woff': 'font/woff',
+ '.woff2': 'font/woff2',
+ '.ttf': 'font/ttf',
+ '.txt': 'text/plain; charset=utf-8',
+}
+
+function docsStaticPlugin(): Plugin {
+ const docsDir = path.resolve(__dirname, '../docs/public')
+
+ function ensureDocsBuilt() {
+ if (!fs.existsSync(path.resolve(docsDir, 'index.html'))) {
+ try {
+ execSync('hugo -s ../docs', { stdio: 'ignore' })
+ } catch {
+ // Hugo may not be installed in minimal test environments
+ }
+ }
+ }
+
+ function serveFromDocs(
+ req: { url?: string },
+ res: {
+ writeHead: (status: number, headers?: Record) => void
+ end: () => void
+ },
+ next: () => void,
+ ) {
+ if (!req.url) return next()
+ const [pathname, search] = req.url.split('?')
+ const query = search ? `?${search}` : ''
+
+ if (pathname === '/docs') {
+ res.writeHead(301, { Location: `/docs/${query}` })
+ res.end()
+ return
+ }
+
+ if (pathname.startsWith('/docs/')) {
+ const relPath = pathname.replace(/^\/docs\//, '')
+ let candidate = path.resolve(docsDir, relPath)
+
+ // Prevent directory traversal outside docsDir
+ if (!candidate.startsWith(docsDir)) {
+ res.writeHead(403)
+ res.end()
+ return
+ }
+
+ if (fs.existsSync(candidate)) {
+ const stat = fs.statSync(candidate)
+ if (stat.isDirectory()) {
+ if (!pathname.endsWith('/')) {
+ res.writeHead(301, { Location: `${pathname}/${query}` })
+ res.end()
+ return
+ }
+ candidate = path.resolve(candidate, 'index.html')
+ }
+ } else {
+ const asHtml = `${candidate}.html`
+ if (fs.existsSync(asHtml)) candidate = asHtml
+ }
+
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
+ const ext = path.extname(candidate).toLowerCase()
+ const mime = MIME_TYPES[ext] ?? 'application/octet-stream'
+ res.writeHead(200, {
+ 'Content-Type': mime,
+ 'Cache-Control': 'no-cache',
+ })
+ fs.createReadStream(candidate).pipe(res as any)
+ return
+ }
+ }
+
+ next()
+ }
+
+ return {
+ name: 'mist-docs-static',
+ buildStart() {
+ ensureDocsBuilt()
+ },
+ closeBundle() {
+ ensureDocsBuilt()
+ const distDocs = path.resolve(__dirname, 'dist/docs')
+ if (fs.existsSync(docsDir)) {
+ fs.cpSync(docsDir, distDocs, { recursive: true })
+ }
+ },
+ configureServer(server) {
+ ensureDocsBuilt()
+ server.middlewares.use(serveFromDocs)
+ },
+ }
+}
+
const config = defineConfig({
server: {
proxy: {
@@ -15,7 +133,7 @@ const config = defineConfig({
},
'/api': {
target: process.env.MIST_API_PROXY ?? 'http://127.0.0.1:3000',
- rewrite: (path) => path.replace(/^\/api/, ''),
+ rewrite: (p) => p.replace(/^\/api/, ''),
},
},
},
@@ -23,6 +141,7 @@ const config = defineConfig({
devtools(),
tsconfigPaths({ projects: ['./tsconfig.json'] }),
tailwindcss(),
+ docsStaticPlugin(),
tanstackRouter({ target: 'react', autoCodeSplitting: true }),
viteReact(),
],