docs(reference): import Dune: Awakening server-manager references
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 39s
CI / integration (push) Successful in 22s

Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.

- icehunter/    dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
                closest analog to our agent's Dune docker control plane (compose
                lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/  Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
                daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
                Hyper-V self-host path + game-config schema

See docs/reference-repos/README.md for the full index + how we use each.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 21:08:05 -04:00
parent 0715492ddf
commit 651a35d4be
1334 changed files with 238971 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
superpowers/
.superpowers/
.wrangler/
settings.local.json

View File

@@ -0,0 +1 @@
auto-install-peers=true

View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,40 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
import stylistic from "@stylistic/eslint-plugin";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
stylistic.configs.recommended,
],
languageOptions: {
globals: globals.browser,
},
rules: {
// Add/override style rules here
"@stylistic/comma-dangle": ["error", "always-multiline"],
"@stylistic/object-curly-spacing": ["error", "always"],
"@stylistic/array-bracket-spacing": ["error", "never"],
"@stylistic/arrow-parens": ["error", "always"],
"@stylistic/max-len": [
"warn",
{
code: 120,
ignoreUrls: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
},
],
},
},
]);

View File

@@ -0,0 +1,12 @@
const config = {
input: ['src/**/*.{ts,tsx}'],
output: 'src/locales/$LOCALE/translation.json',
locales: ['en-US', 'de', 'fr', 'es', 'pt-BR', 'ru', 'pl', 'tr', 'zh-CN', 'ja'],
defaultNamespace: 'translation',
defaultValue: (locale: string) => (locale === 'en-US' ? '' : 'MISSING'),
keySeparator: '.',
namespaceSeparator: false,
sort: true,
}
export default config

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dune Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,68 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"packageManager": "pnpm@10.28.1",
"scripts": {
"typecheck": "tsc --noEmit",
"dev": "vite",
"build": "tsc --noEmit && vite build",
"lint": "eslint . --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@clerk/react": "^6.7.1",
"@clerk/themes": "^2.4.57",
"@codemirror/autocomplete": "^6.20.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@heroui/react": "^3.0.5",
"@heroui/styles": "^3.0.5",
"@iconify-json/lucide": "^1.2.109",
"@iconify/react": "^6.0.2",
"@internationalized/date": "^3.12.2",
"@lezer/highlight": "^1.2.3",
"@tailwindcss/vite": "^4.3.0",
"@uiw/codemirror-themes": "^4.25.10",
"@uiw/react-codemirror": "^4.25.10",
"i18next": "^26.3.0",
"jotai": "^2.20.0",
"leaflet": "^1.9.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.15.1",
"recharts": "^3.8.1",
"tailwindcss": "^4.3.0",
"tslib": "^2.8.1",
"vitest": "^4.1.7"
},
"overrides": {
"js-cookie": "3.0.7"
},
"pnpm": {
"overrides": {
"@internationalized/date": "3.12.2"
}
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@rolldown/binding-win32-x64-msvc": "^1.0.3",
"@stylistic/eslint-plugin": "^5.10.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@@ -0,0 +1,706 @@
import type React from 'react'
import { memo, useState, useCallback, useEffect, useRef, type ReactNode } from 'react'
import { Show, SignInButton, UserButton, useAuth } from '@clerk/react'
import { Button, Chip, Modal, Spinner, Tabs, Toast, ToggleButton, ToggleButtonGroup, toast } from '@heroui/react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useStatus } from './hooks/useStatus'
import { BackendUnreachable } from './components/BackendUnreachable'
import { SettingsConfigForm } from './components/SettingsConfigForm'
import { LanguageSelector } from './components/LanguageSelector'
import { ThemeSelector } from './components/ThemeSelector'
import { HelpMenu } from './components/HelpMenu'
import { BattlegroupTab } from './tabs/BattlegroupTab'
import { LiveMapTab } from './tabs/LiveMapTab'
import { PlayersTab } from './tabs/PlayersTab'
import { DatabaseTab } from './tabs/DatabaseTab'
import { LogsTab } from './tabs/LogsTab'
import { BlueprintsTab } from './tabs/BlueprintsTab'
import { BasesTab } from './tabs/BasesTab'
import { GuildsTab } from './tabs/GuildsTab'
import { LandsraadTab } from './tabs/LandsraadTab'
import { StorageTab } from './tabs/StorageTab'
import { ServerSettingsTab } from './tabs/ServerSettingsTab'
import { DirectorTab } from './tabs/DirectorTab'
import { MarketTab } from './tabs/MarketTab'
import { WelcomePackageTab } from './tabs/WelcomePackageTab'
import { Icon, SideNav } from './dune-ui'
import { api } from './api/client'
import type { UpdateCheckResult } from './api/client'
const TAB_IDS = [
'battlegroup',
'players',
'database',
'logs',
'blueprints',
'bases',
'guilds',
'landsraad',
'storage',
'livemap',
'server',
'director',
'market',
'welcome',
] as const
type TabId = (typeof TAB_IDS)[number]
const DEFAULT_TAB: TabId = 'battlegroup'
function currentTabFromPath(pathname: string): TabId {
const seg = pathname.replace(/^\//, '').split('/')[0]
return (TAB_IDS as readonly string[]).includes(seg) ? (seg as TabId) : DEFAULT_TAB
}
type DbSection = 'backups' | 'tables' | 'describe' | 'sample' | 'search' | 'sql'
type WelcomeSection = 'config' | 'packages' | 'grants'
type LayoutMode = 'sidenav' | 'topnav'
// Memoized at module level so identity is stable — prevents all inactive tabs from
// re-rendering whenever AppCore re-renders (e.g. router location change, useStatus poll).
const MBattlegroupTab = memo(BattlegroupTab)
const MLiveMapTab = memo(LiveMapTab)
const MPlayersTab = memo(PlayersTab)
const MDatabaseTab = memo(DatabaseTab)
const MLogsTab = memo(LogsTab)
const MBlueprintsTab = memo(BlueprintsTab)
const MBasesTab = memo(BasesTab)
const MGuildsTab = memo(GuildsTab)
const MLandsraadTab = memo(LandsraadTab)
const MStorageTab = memo(StorageTab)
const MServerSettingsTab = memo(ServerSettingsTab)
const MDirectorTab = memo(DirectorTab)
const MMarketTab = memo(MarketTab)
const MWelcomePackageTab = memo(WelcomePackageTab)
const hasClerk = !!import.meta.env.VITE_CLERK_PUBLISHABLE_KEY
interface AppCoreProps {
isSignedIn: boolean
}
interface TabPaneProps {
active: boolean
children: ReactNode
}
interface ConnectionBadgeProps {
label: string
connected: boolean
}
function AppWithAuth() {
const { isSignedIn } = useAuth()
return <AppCore isSignedIn={!!isSignedIn} />
}
export const App: React.FC = () => {
return hasClerk ? <AppWithAuth /> : <AppCore isSignedIn={true} />
}
const AppCore: React.FC<AppCoreProps> = ({ isSignedIn }) => {
const { status, state: connState } = useStatus()
const location = useLocation()
const navigate = useNavigate()
const { t, i18n } = useTranslation()
const [reconnecting, setReconnecting] = useState(false)
const DB_SECTIONS: { key: string, label: string, depth: number }[] = [
{ key: 'db:backups', label: `╰─ ${t('database.sections.backups')}`, depth: 1 },
{ key: 'db:tables', label: `╰─ ${t('database.sections.tables')}`, depth: 1 },
{ key: 'db:describe', label: `╰─ ${t('database.sections.describe')}`, depth: 1 },
{ key: 'db:sample', label: `╰─ ${t('database.sections.sample')}`, depth: 1 },
{ key: 'db:search', label: `╰─ ${t('database.sections.search')}`, depth: 1 },
{ key: 'db:sql', label: `╰─ ${t('database.sections.sql')}`, depth: 1 },
]
const WELCOME_SECTIONS: { key: string, label: string, depth: number }[] = [
{ key: 'welcome:config', label: `╰─ ${t('welcome.sections.config')}`, depth: 1 },
{ key: 'welcome:packages', label: `╰─ ${t('welcome.sections.packages')}`, depth: 1 },
{ key: 'welcome:grants', label: `╰─ ${t('welcome.sections.grants')}`, depth: 1 },
]
// Re-establish backend connections (DB + control plane) without a service
// restart — used by the header Reconnect button when the DB shows disconnected
// (e.g. dune-admin came up before the database was ready).
const handleReconnect = async () => {
setReconnecting(true)
try {
const s = await api.reconnect()
if (s.db_connected) toast.success(t('app.reconnected'))
else toast.danger(t('app.reconnectFailed', { error: 'database still unreachable' }))
}
catch (e) {
toast.danger(t('app.reconnectFailed', { error: e instanceof Error ? e.message : String(e) }))
}
finally {
setReconnecting(false)
}
}
// Left-sidebar navigation, grouped to mirror the product's structure
// (operator tooling today; a Player Portal group lands here later).
const NAV_GROUPS: { title: string, items: { key: TabId, label: string }[] }[] = [
{
title: t('nav.groups.operations'),
items: [
{ key: 'battlegroup' as TabId, label: t('nav.battlegroup') },
{ key: 'logs' as TabId, label: t('nav.logs') },
{ key: 'database' as TabId, label: t('nav.database') },
{ key: 'server' as TabId, label: t('nav.server') },
{ key: 'director' as TabId, label: t('nav.director') },
],
},
{
title: t('nav.groups.playerWorld'),
items: [
{ key: 'players' as TabId, label: t('nav.players') },
{ key: 'livemap' as TabId, label: t('nav.liveMap') },
{ key: 'storage' as TabId, label: t('nav.storage') },
{ key: 'bases' as TabId, label: t('nav.bases') },
{ key: 'guilds' as TabId, label: t('nav.guilds') },
{ key: 'landsraad' as TabId, label: t('nav.landsraad') },
{ key: 'blueprints' as TabId, label: t('nav.blueprints') },
],
},
{
title: t('nav.groups.economy'),
items: [
{ key: 'market' as TabId, label: t('nav.market') },
{ key: 'welcome' as TabId, label: t('nav.welcome') },
],
},
]
const [layoutMode, setLayoutMode] = useState<LayoutMode>(
() => (localStorage.getItem('dune_admin_layout') === 'topnav' ? 'topnav' : 'sidenav'),
)
const setLayout = useCallback((m: LayoutMode) => {
localStorage.setItem('dune_admin_layout', m)
setLayoutMode(m)
}, [])
const [dbSection, setDbSection] = useState<DbSection>('backups')
const [welcomeSection, setWelcomeSection] = useState<WelcomeSection>('config')
const [showBackendConfig, setShowBackendConfig] = useState(false)
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null)
const [showUpdateModal, setShowUpdateModal] = useState(false)
const [updateChecking, setUpdateChecking] = useState(false)
const [updateApplying, setUpdateApplying] = useState(false)
const [formSaving, setFormSaving] = useState(false)
const formSaveRef = useRef<(() => Promise<void>) | null>(null)
useEffect(() => {
const seg = location.pathname.replace(/^\//, '').split('/')[0]
if (!seg || !(TAB_IDS as readonly string[]).includes(seg)) {
navigate(`/${DEFAULT_TAB}`, { replace: true })
}
}, [location.pathname, navigate])
const currentTab = currentTabFromPath(location.pathname)
// Tracks which tabs have been visited at least once — they get mounted and stay
// mounted (TabPane keeps them hidden), preserving in-tab state and the isActive
// auto-refresh contract. Unvisited tabs never mount, avoiding the startup query storm.
const [mounted, setMounted] = useState<Set<TabId>>(() => new Set<TabId>([currentTab]))
useEffect(() => {
setMounted((prev) => { // eslint-disable-line react-hooks/set-state-in-effect
if (prev.has(currentTab)) return prev
const next = new Set(prev)
next.add(currentTab)
return next
})
}, [currentTab])
// Check for a newer release via the backend (it knows this build's version and
// returns the release-notes URL) — drives the clickable header update widget (#129).
useEffect(() => {
api.update.check().then(setUpdateInfo).catch(() => {})
}, [])
const checkUpdate = async () => {
setUpdateChecking(true)
try {
setUpdateInfo(await api.update.check())
}
catch {
// silently ignore — user can retry
}
finally {
setUpdateChecking(false)
}
}
const applyUpdate = async (force = false) => {
setUpdateApplying(true)
try {
const result = await api.update.apply(force)
if (result.updated) {
toast.success(force ? t('app.reinstalled', { version: result.version ?? 'latest' }) : t('app.updated', { version: result.version ?? 'latest' }))
setUpdateInfo(null)
setTimeout(() => {
window.location.reload()
}, 1500)
}
else {
toast.info(result.message)
}
}
catch (e) {
toast.danger(t('app.updateFailed', { message: e instanceof Error ? e.message : String(e) }))
}
finally {
setUpdateApplying(false)
}
}
const renderTab = (id: TabId, node: ReactNode) => (
<TabPane active={currentTab === id}>
{mounted.has(id) ? node : null}
</TabPane>
)
// #165: when the SPA never reached the backend, show an informative setup
// screen instead of an empty, non-working dashboard.
if (connState === 'error') {
return <BackendUnreachable onRetry={() => window.location.reload()} />
}
return (
// Keyed on the active language so switching language remounts the content
// subtree once. The module-level memo() tabs stay mounted and otherwise keep
// stale-language text on a language change (their props don't change), until
// an unrelated local state update forces them to re-render (#123).
<div key={i18n.language} className="h-screen flex flex-col overflow-hidden bg-background">
<Toast.Provider />
{/* Header */}
<header
className="flex items-center justify-between px-6 py-3 border-b border-border bg-surface shrink-0"
style={{ background: 'linear-gradient(180deg, var(--surface-secondary) 0%, var(--surface) 100%)' }}
>
<div className="flex items-center gap-3">
<Button
variant="ghost"
className="text-xl font-bold uppercase tracking-[0.2em] text-accent px-0 h-auto min-w-0 hover:opacity-80"
onPress={() => navigate(`/${DEFAULT_TAB}`)}
aria-label={t('app.goHome')}
>
{t('app.title')}
</Button>
{status?.control && status.control !== 'none' && <span className="text-xs text-muted">{status.control}</span>}
{status?.ssh_host && <span className="text-xs text-muted">{status.ssh_host}</span>}
{status?.db_host && status.control !== 'kubectl' && (
<span className="text-xs text-muted">{status.db_host}</span>
)}
{status?.version && (
<Button
variant="ghost"
className="text-xs text-muted hover:text-foreground px-0 h-auto min-w-0"
onPress={() => setShowBackendConfig(true)}
aria-label={t('app.openSettings')}
>
v
{status.version}
</Button>
)}
{updateInfo?.needs_update && (
<button
type="button"
onClick={() => setShowUpdateModal(true)}
aria-label={t('app.updateAvailable')}
className="cursor-pointer border-0 bg-transparent p-0"
>
<Chip size="sm" color="warning" variant="soft">
{' '}
{updateInfo.latest.replace(/^v/, '')}
</Chip>
</button>
)}
</div>
<div className="flex items-center gap-3">
{status?.executor === 'ssh' && <ConnectionBadge label="SSH" connected={status.ssh_connected} />}
<ConnectionBadge label="DB" connected={status?.db_connected ?? false} />
{status && !status.db_connected && (
<Button
size="sm"
variant="outline"
isDisabled={reconnecting}
onPress={handleReconnect}
>
{reconnecting ? t('app.reconnecting') : t('app.reconnect')}
</Button>
)}
{status?.pod_ns && (
<span className="text-xs text-muted">
ns:
{status.pod_ns}
</span>
)}
<HelpMenu status={status} />
<ThemeSelector />
<LanguageSelector />
<ToggleButtonGroup
selectionMode="single"
disallowEmptySelection
selectedKeys={[layoutMode]}
onSelectionChange={(keys) => {
const next = [...keys][0]
if (next === 'sidenav' || next === 'topnav') setLayout(next)
}}
>
<ToggleButton id="sidenav" isIconOnly aria-label={t('app.switchToSidenav')}>
<Icon name="layout-panel-left" />
</ToggleButton>
<ToggleButton id="topnav" isIconOnly aria-label={t('app.switchToTopnav')}>
<Icon name="layout-panel-top" />
</ToggleButton>
</ToggleButtonGroup>
<Button
size="sm"
variant="outline"
aria-label={t('app.configureBackend')}
onPress={() => setShowBackendConfig((v) => !v)}
className={showBackendConfig ? 'text-accent border-accent' : ''}
>
<Icon name="settings" />
{' '}
{t('app.settings')}
</Button>
{hasClerk && (
<>
<Show when="signed-out">
<SignInButton>
<Button size="sm" variant="outline">
{t('app.signIn')}
</Button>
</SignInButton>
</Show>
<Show when="signed-in">
<UserButton />
</Show>
</>
)}
</div>
</header>
{/* Settings modal — structure mirrors BotControlPanel */}
<Modal>
<Modal.Backdrop isOpen={showBackendConfig} onOpenChange={(v) => !v && setShowBackendConfig(false)}>
<Modal.Container size="cover" scroll="outside">
<Modal.Dialog className="h-[92vh] flex flex-col">
<Modal.CloseTrigger />
<Modal.Header>
<div className="flex items-baseline gap-6 flex-wrap">
<Modal.Heading className="text-accent">{t('app.settings')}</Modal.Heading>
{status && (
<div className="flex items-center gap-4 text-xs text-muted">
{status.version && (
<span className="font-mono">
v
{status.version}
</span>
)}
{status.control && status.control !== 'none' && <span>{status.control}</span>}
{status.commit && status.commit !== 'unknown' && (
<span className="font-mono opacity-60">{status.commit}</span>
)}
</div>
)}
</div>
</Modal.Header>
{/* Body scrolls; form fills it with its own internal tab scroll */}
<Modal.Body className="flex flex-col overflow-y-auto flex-1 min-h-0 pr-1">
{showBackendConfig && (
<SettingsConfigForm saveRef={formSaveRef} onSavingChange={setFormSaving} />
)}
</Modal.Body>
<Modal.Footer className="flex items-center gap-2">
{/* Left: update controls — fixed positions so buttons don't shift */}
<Button
size="sm"
variant="ghost"
onPress={checkUpdate}
isDisabled={updateChecking || updateApplying}
>
{updateChecking
? (
<>
<Spinner size="sm" color="current" />
{' '}
{t('common.checking')}
</>
)
: t('app.checkUpdates')}
</Button>
{updateInfo && !updateInfo.needs_update && (
<Button
size="sm"
variant="ghost"
onPress={() => applyUpdate(true)}
isDisabled={updateApplying}
>
{updateApplying ? <Spinner size="sm" color="current" /> : t('app.reinstall')}
</Button>
)}
{updateInfo?.needs_update && (
<Button size="sm" onPress={() => applyUpdate()} isDisabled={updateApplying}>
{updateApplying
? <Spinner size="sm" color="current" />
: (
<span className="font-mono text-xs">
v
{updateInfo.current}
{' → '}
v
{updateInfo.latest.replace(/^v/, '')}
</span>
)}
</Button>
)}
{/* Spacer */}
<span className="flex-1" />
{/* Right: save + close */}
<span className="text-xs text-muted">{t('app.changesNote')}</span>
<Button
size="sm"
onPress={() => formSaveRef.current?.()}
isDisabled={formSaving}
>
{formSaving
? (
<>
<Spinner size="sm" color="current" />
{' '}
{t('common.saving')}
</>
)
: (
<>
<Icon name="save" />
{' '}
{t('app.saveApply')}
</>
)}
</Button>
<Button
size="sm"
variant="tertiary"
onPress={() => setShowBackendConfig(false)}
>
{t('common.close')}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
{/* Update-available prompt — opened from the header release widget (#129).
Reuses the backend update check for the release-notes link + Continue/Cancel. */}
<Modal>
<Modal.Backdrop isOpen={showUpdateModal} onOpenChange={(v) => !v && setShowUpdateModal(false)}>
<Modal.Container size="sm">
<Modal.Dialog>
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading className="text-accent">{t('app.updateAvailable')}</Modal.Heading>
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">
{t('app.updateAvailableBody', {
current: updateInfo?.current ?? '',
latest: updateInfo?.latest?.replace(/^v/, '') ?? '',
})}
</p>
{updateInfo?.release_url && (
<a
href={updateInfo.release_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm text-accent hover:opacity-80"
>
<Icon name="external-link" />
{' '}
{t('app.viewReleaseNotes')}
</a>
)}
</Modal.Body>
<Modal.Footer className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="tertiary"
onPress={() => setShowUpdateModal(false)}
>
{t('common.cancel')}
</Button>
<Button
size="sm"
onPress={() => {
setShowUpdateModal(false)
void applyUpdate()
}}
isDisabled={updateApplying}
>
{updateApplying ? <Spinner size="sm" color="current" /> : t('app.updateNow')}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
{/* Body: layout-conditional rendering.
In sidenav mode: grouped left sidebar + content.
In topnav mode: horizontal nav bar + full-width content.
All tabs stay mounted (inactive hidden) so per-tab state and isActive
auto-refresh behavior persist. */}
{(() => {
const databaseNode = layoutMode === 'topnav'
? <MDatabaseTab section={dbSection} showSubnav onSectionChange={setDbSection} />
: <MDatabaseTab section={dbSection} />
const welcomeNode = layoutMode === 'topnav'
? <MWelcomePackageTab section={welcomeSection} showSubnav onSectionChange={setWelcomeSection} />
: <MWelcomePackageTab section={welcomeSection} />
if (layoutMode === 'sidenav') {
return (
<div className="flex-1 flex gap-3 p-3 overflow-hidden min-h-0">
<nav className="w-60 shrink-0 flex flex-col gap-2 overflow-y-auto">
{/* Operations: rendered separately so Database can expand DB sub-items inline */}
<SideNav
width="w-full"
title={NAV_GROUPS[0].title}
items={[
...NAV_GROUPS[0].items.slice(0, 3),
...(currentTab === 'database' ? DB_SECTIONS : []),
...NAV_GROUPS[0].items.slice(3),
] as { key: string, label: string, depth?: number }[]}
active={currentTab === 'database' ? `db:${dbSection}` : currentTab}
onSelect={(k: string) => {
if (k.startsWith('db:')) {
setDbSection(k.slice(3) as DbSection)
if (currentTab !== 'database') navigate('/database')
}
else {
navigate(`/${k}`)
}
}}
/>
{/* Player World: unchanged */}
<SideNav
key={NAV_GROUPS[1].title}
width="w-full"
title={NAV_GROUPS[1].title}
items={NAV_GROUPS[1].items}
active={currentTab}
onSelect={(k) => navigate(`/${k}`)}
/>
{/* Economy: expand Welcome sub-items inline */}
<SideNav
width="w-full"
title={NAV_GROUPS[2].title}
items={[
...NAV_GROUPS[2].items,
...(currentTab === 'welcome' ? WELCOME_SECTIONS : []),
] as { key: string, label: string, depth?: number }[]}
active={currentTab === 'welcome' ? `welcome:${welcomeSection}` : currentTab}
onSelect={(k: string) => {
if (k.startsWith('welcome:')) {
setWelcomeSection(k.slice(8) as WelcomeSection)
if (currentTab !== 'welcome') navigate('/welcome')
}
else {
navigate(`/${k}`)
}
}}
/>
</nav>
<main className="flex-1 flex flex-col overflow-hidden min-h-0">
{renderTab('battlegroup', <MBattlegroupTab isActive={currentTab === 'battlegroup'} />)}
{renderTab('players', <MPlayersTab isActive={currentTab === 'players'} />)}
{renderTab('database', databaseNode)}
{renderTab('logs', <MLogsTab control={status?.control} />)}
{renderTab('blueprints', <MBlueprintsTab isSignedIn={isSignedIn} />)}
{renderTab('bases', <MBasesTab isSignedIn={isSignedIn} />)}
{renderTab('guilds', <MGuildsTab isSignedIn={isSignedIn} />)}
{renderTab('landsraad', <MLandsraadTab />)}
{renderTab('storage', <MStorageTab />)}
{renderTab('livemap', <MLiveMapTab isActive={currentTab === 'livemap'} />)}
{renderTab('server', <MServerSettingsTab />)}
{renderTab('director', <MDirectorTab />)}
{renderTab('market', <MMarketTab />)}
{renderTab('welcome', welcomeNode)}
</main>
</div>
)
}
// topnav mode: horizontal nav bar + full-width content area
return (
<>
<Tabs
selectedKey={currentTab}
onSelectionChange={(k) => navigate(`/${String(k)}`)}
className="shrink-0 border-b border-border bg-surface"
>
<Tabs.ListContainer className="px-3 py-2 overflow-x-auto">
<Tabs.List aria-label={t('app.title')}>
{NAV_GROUPS.flatMap((g) => g.items).map((item) => (
<Tabs.Tab key={item.key} id={item.key}>
{item.label}
<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
<div className="flex-1 flex flex-col p-3 overflow-hidden min-h-0">
<main className="flex-1 flex flex-col overflow-hidden min-h-0">
{renderTab('battlegroup', <MBattlegroupTab isActive={currentTab === 'battlegroup'} />)}
{renderTab('players', <MPlayersTab isActive={currentTab === 'players'} />)}
{renderTab('database', databaseNode)}
{renderTab('logs', <MLogsTab control={status?.control} />)}
{renderTab('blueprints', <MBlueprintsTab isSignedIn={isSignedIn} />)}
{renderTab('bases', <MBasesTab isSignedIn={isSignedIn} />)}
{renderTab('guilds', <MGuildsTab isSignedIn={isSignedIn} />)}
{renderTab('landsraad', <MLandsraadTab />)}
{renderTab('storage', <MStorageTab />)}
{renderTab('livemap', <MLiveMapTab isActive={currentTab === 'livemap'} />)}
{renderTab('server', <MServerSettingsTab />)}
{renderTab('director', <MDirectorTab />)}
{renderTab('market', <MMarketTab />)}
{renderTab('welcome', welcomeNode)}
</main>
</div>
</>
)
})()}
</div>
)
}
function TabPane({ active, children }: TabPaneProps) {
return (
<div className={`flex-1 min-h-0 ${active ? 'flex flex-col dune-tab-active' : 'hidden'}`}>
{children}
</div>
)
}
function ConnectionBadge({ label, connected }: ConnectionBadgeProps) {
return (
<div className="flex items-center gap-1.5 text-xs">
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-success' : 'bg-muted/40'}`} />
<span className={connected ? 'text-foreground' : 'text-muted'}>{label}</span>
</div>
)
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,35 @@
import type React from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@heroui/react'
import { Icon, Panel } from '../dune-ui'
import { currentBackendBase } from '../api/client'
// BackendUnreachable is shown when the SPA loaded but could never reach the
// dune-admin backend API (#165) — instead of an empty, non-working dashboard.
// It surfaces the backend target it's trying and how to fix a connection issue.
export const BackendUnreachable: React.FC<{ onRetry: () => void }> = ({ onRetry }) => {
const { t } = useTranslation()
return (
<div className="flex items-center justify-center min-h-screen bg-background p-6">
<Panel className="max-w-lg w-full flex flex-col items-center gap-4 py-8 text-center">
<Icon name="triangle-alert" className="text-warning" />
<h1 className="text-lg font-semibold text-foreground">{t('app.backendUnreachable.title')}</h1>
<p className="text-sm text-muted">{t('app.backendUnreachable.body')}</p>
<div className="text-xs text-muted flex flex-col items-center gap-0.5">
<span>{t('app.backendUnreachable.targetLabel')}</span>
<span className="font-mono text-foreground break-all">{currentBackendBase()}</span>
</div>
<ul className="text-sm text-muted text-left list-disc pl-5 space-y-1">
<li>{t('app.backendUnreachable.hint1')}</li>
<li>{t('app.backendUnreachable.hint2')}</li>
<li>{t('app.backendUnreachable.hint3')}</li>
</ul>
<Button size="sm" onPress={onRetry}>
<Icon name="refresh-cw" />
{' '}
{t('app.backendUnreachable.retry')}
</Button>
</Panel>
</div>
)
}

View File

@@ -0,0 +1,74 @@
import type React from 'react'
import { Dropdown, Button, toast } from '@heroui/react'
import { useTranslation } from 'react-i18next'
import { Icon } from '../dune-ui'
import { copyText } from '../utils/clipboard'
import type { Status } from '../api/client'
const REPO = 'https://github.com/Icehunter/dune-admin'
function buildDiagnostics(status?: Status | null): string {
return [
`- dune-admin version: ${status?.version ?? 'unknown'}`,
`- commit: ${status?.commit ?? 'unknown'}`,
`- build: ${status?.build_time ?? 'unknown'}`,
`- control plane: ${status?.control ?? 'unknown'}`,
`- executor: ${status?.executor ?? 'unknown'}`,
`- db connected: ${status?.db_connected ?? false}`,
`- ssh connected: ${status?.ssh_connected ?? false}`,
`- browser: ${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}`,
].join('\n')
}
export const HelpMenu: React.FC<{ status?: Status | null }> = ({ status }) => {
const { t } = useTranslation()
const reportIssue = () => {
const body = `## Describe the issue\n\n<!-- What happened? What did you expect? Steps to reproduce. -->\n\n## Environment (auto-filled by dune-admin)\n${buildDiagnostics(status)}\n`
window.open(`${REPO}/issues/new?body=${encodeURIComponent(body)}`, '_blank', 'noopener,noreferrer')
}
const copyDiagnostics = () => {
copyText(buildDiagnostics(status)).then((ok) => {
if (ok) toast.success(t('help.copied'))
else toast.danger(t('help.copyFailed'))
})
}
const openRepo = () => {
window.open(REPO, '_blank', 'noopener,noreferrer')
}
const items = [
{ id: 'report', icon: 'bug', label: t('help.reportIssue'), action: reportIssue },
{ id: 'copy', icon: 'clipboard', label: t('help.copyDiagnostics'), action: copyDiagnostics },
{ id: 'repo', icon: 'github', label: t('help.viewOnGitHub'), action: openRepo },
]
return (
<Dropdown>
<Button
isIconOnly
variant="ghost"
size="sm"
aria-label={t('help.menu')}
className="w-8 h-8 min-w-0 text-muted data-[hover=true]:text-foreground data-[hover=true]:bg-surface-secondary"
>
<Icon name="circle-help" />
</Button>
<Dropdown.Popover>
<Dropdown.Menu
aria-label={t('help.menu')}
onAction={(key) => items.find((i) => i.id === String(key))?.action()}
>
{items.map((it) => (
<Dropdown.Item key={it.id} id={it.id} textValue={it.label}>
<span className="flex items-center gap-2">
<Icon name={it.icon} className="w-4 h-4 text-muted" />
<span>{it.label}</span>
</span>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
)
}

View File

@@ -0,0 +1,52 @@
import type React from 'react'
import { useState } from 'react'
import { Dropdown, Button } from '@heroui/react'
import { useTranslation } from 'react-i18next'
import { LANGUAGES, setLocale, LOCALE_KEY, DEFAULT_LOCALE } from '../i18n'
export const LanguageSelector: React.FC = () => {
const { t } = useTranslation()
const [current, setCurrent] = useState(
localStorage.getItem(LOCALE_KEY) ?? DEFAULT_LOCALE,
)
const selected = LANGUAGES.find((l) => l.code === current) ?? LANGUAGES[0]
return (
<Dropdown>
<Button
isIconOnly
variant="ghost"
size="sm"
aria-label={t('app.selectLanguage')}
className="w-8 h-8 min-w-0 text-base data-[hover=true]:bg-surface-secondary"
>
{selected.flag}
</Button>
<Dropdown.Popover>
<Dropdown.Menu
aria-label={t('app.selectLanguage')}
selectionMode="single"
selectedKeys={new Set([current])}
onSelectionChange={(keys) => {
if (keys === 'all') return
const code = [...keys][0] as string
if (code) {
setLocale(code)
setCurrent(code)
}
}}
>
{LANGUAGES.map((lang) => (
<Dropdown.Item key={lang.code} id={lang.code} textValue={lang.label}>
<span className="flex items-center gap-2">
<span>{lang.flag}</span>
<span>{lang.label}</span>
</span>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
)
}

View File

@@ -0,0 +1,163 @@
import { useState, useEffect, useCallback } from 'react'
import type React from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Spinner, Switch, ToggleButton, ToggleButtonGroup, toast } from '@heroui/react'
import { api } from '../api/client'
import type { ScheduledRestarts, RestartRule } from '../api/client'
import { Panel, SectionLabel, Icon, NumberInput, TimeInput } from '../dune-ui'
import { TimezoneSelect } from './TimezoneSelect'
const DOW = [0, 1, 2, 3, 4, 5, 6] // Sun..Sat
// ScheduledRestartsCard (#145): configure weekday+time auto-restarts with a
// native in-game countdown warning. Designed as a card to drop into the Server
// Health page (#149); lives on the Battlegroup tab until that lands.
export const ScheduledRestartsCard: React.FC = () => {
const { t, i18n } = useTranslation()
const [data, setData] = useState<ScheduledRestarts | null>(null)
const [enabled, setEnabled] = useState(false)
const [timezone, setTimezone] = useState('')
const [warn, setWarn] = useState(10)
const [rules, setRules] = useState<RestartRule[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const apply = (d: ScheduledRestarts) => {
setData(d)
setEnabled(d.enabled)
setTimezone(d.timezone)
setWarn(d.warn_minutes || 10)
setRules(d.rules ?? [])
}
const load = useCallback(() => {
Promise.resolve()
.then(() => setLoading(true))
.then(() => api.scheduledRestarts.get())
.then(apply)
.catch((e: unknown) =>
toast.danger(t('restarts.failedToLoad', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setLoading(false))
}, [t])
useEffect(() => {
load()
}, [load])
const save = () => {
setSaving(true)
api.scheduledRestarts.update({ enabled, timezone, rules, warn_minutes: warn })
.then((res) => {
toast.success(res.ok)
load()
})
.catch((e: unknown) =>
toast.danger(t('restarts.saveFailed', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setSaving(false))
}
const skip = () => {
api.scheduledRestarts.skipNext()
.then((res) => {
toast.success(res.ok)
load()
})
.catch((e: unknown) =>
toast.danger(t('restarts.saveFailed', { message: e instanceof Error ? e.message : String(e) })))
}
const addRule = () => setRules((r) => [...r, { days: [...DOW], time: '04:00' }])
const removeRule = (i: number) => setRules((r) => r.filter((_, idx) => idx !== i))
const setRuleTime = (i: number, time: string) =>
setRules((r) => r.map((rule, idx) => (idx === i ? { ...rule, time } : rule)))
const setRuleDays = (i: number, days: number[]) =>
setRules((r) => r.map((rule, idx) => (idx === i ? { ...rule, days } : rule)))
// Localized short weekday label (Jan 1 2023 was a Sunday = day 0).
const dowLabel = (d: number) =>
new Intl.DateTimeFormat(i18n.language, { weekday: 'short' }).format(new Date(Date.UTC(2023, 0, 1 + d)))
return (
<Panel>
<div className="flex items-center justify-between mb-2">
<SectionLabel>{t('restarts.title')}</SectionLabel>
<Switch isSelected={enabled} onChange={setEnabled} size="sm" className="text-xs text-muted">
<Switch.Control><Switch.Thumb /></Switch.Control>
<Switch.Content>{t('restarts.enable')}</Switch.Content>
</Switch>
</div>
{loading
? <div className="py-4 flex justify-center"><Spinner size="sm" color="current" /></div>
: (
<>
<div className="text-sm mb-3">
{enabled && data?.next_restart
? (
<span className="text-success">
{t('restarts.nextRestart', { when: new Date(data.next_restart).toLocaleString() })}
</span>
)
: <span className="text-muted">{t('restarts.noneScheduled')}</span>}
</div>
{rules.length === 0 && <div className="text-xs text-muted mb-2">{t('restarts.noRules')}</div>}
{rules.map((rule, i) => (
<div key={i} className="flex items-center gap-2 mb-2 flex-wrap">
<ToggleButtonGroup
selectionMode="multiple"
selectedKeys={rule.days.map(String)}
onSelectionChange={(keys) => {
const days = [...keys].map(Number).sort((a, b) => a - b)
setRuleDays(i, days)
}}
size="sm"
>
{DOW.map((d) => (
<ToggleButton key={d} id={String(d)}>{dowLabel(d)}</ToggleButton>
))}
</ToggleButtonGroup>
<TimeInput value={rule.time} onChange={(v) => setRuleTime(i, v)} ariaLabel="time" />
<Button size="sm" variant="ghost" isIconOnly aria-label={t('restarts.removeRule')} onPress={() => removeRule(i)}>
<Icon name="x" />
</Button>
</div>
))}
<Button size="sm" variant="outline" className="mb-3" onPress={addRule}>
<Icon name="plus" />
{' '}
{t('restarts.addRule')}
</Button>
<div className="flex items-center gap-4 mb-3 text-sm flex-wrap">
<label className="flex items-center gap-2">
{t('restarts.warnMinutes')}
<NumberInput
value={warn}
onChange={(v) => setWarn(v || 10)}
min={1}
ariaLabel={t('restarts.warnMinutes')}
className="w-16"
showButtons={false}
/>
</label>
<label className="flex items-center gap-2 flex-1 min-w-[160px]">
{t('restarts.timezone')}
<TimezoneSelect value={timezone} onChange={setTimezone} className="flex-1" />
</label>
</div>
<div className="flex gap-2">
<Button size="sm" onPress={save} isDisabled={saving}>
{saving ? <Spinner size="sm" color="current" /> : t('restarts.save')}
</Button>
<Button size="sm" variant="outline" onPress={skip} isDisabled={!enabled || !data?.next_restart}>
{t('restarts.skipNext')}
</Button>
</div>
</>
)}
</Panel>
)
}

View File

@@ -0,0 +1,497 @@
import type React from 'react'
import { useState, useEffect, type MutableRefObject } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Input, Select, ListBox, Spinner, Switch, Tabs, toast } from '@heroui/react'
import { api, MASKED } from '../api/client'
import type { AppConfig } from '../api/client'
import { NumberInput, Panel, SectionLabel } from '../dune-ui'
// ── defaults (all empty — never show fake values) ─────────────────────────────
const EMPTY: AppConfig = {
control: '',
ssh_host: '', ssh_user: '', ssh_key: '',
db_host: '', db_port: 0, db_user: '',
db_pass: '', db_name: '', db_schema: '',
control_namespace: '',
docker_gameserver: '', docker_broker_game: '', docker_broker_admin: '', docker_db: '',
cmd_start: '', cmd_stop: '', cmd_restart: '', cmd_status: '',
broker_game_addr: '', broker_admin_addr: '', broker_tls: false,
broker_user: '', broker_pass: '', broker_jwt_secret: '', broker_exec_prefix: '',
backup_dir: '', server_ini_dir: '', default_ini_dir: '',
amp_instance: '', amp_container: '', amp_user: '', amp_log_path: '',
amp_use_container: false, amp_data_root: '',
amp_api_user: '', amp_api_pass: '', amp_api_port: 0,
director_url: '',
market_bot_enabled: false,
market_bot_cache_db: '', market_bot_item_data: '', market_bot_state: '',
market_bot_buy_interval: '', market_bot_list_interval: '',
market_bot_buy_threshold: 0, market_bot_max_buys: 0,
market_bot_remote_url: '', market_bot_remote_token: '',
listen_addr: '', scrip_currency: 0,
}
// Pointer-backed boolean fields in the Go config: null means "use server
// default" (effectively true). If the API returns null for these, coerce to
// true so the checkbox reflects the real server default rather than silently
// inheriting EMPTY's false and overwriting the default-on value on save.
const pointerBoolFields = new Set<keyof AppConfig>(['amp_use_container', 'market_bot_enabled'])
function mergeConfig(fetched: Record<string, unknown>): AppConfig {
const result: AppConfig = { ...EMPTY }
for (const key of Object.keys(fetched) as (keyof AppConfig)[]) {
const v = fetched[key]
if (v !== null && v !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(result as any)[key] = v
}
else if (v === null && pointerBoolFields.has(key)) {
// Null pointer-backed bool: the server field is unset (default-on).
// Keep the EMPTY default only if it matches server intent (true = default).
// Override EMPTY's false with true so the checkbox reflects the real default.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(result as any)[key] = true
}
}
return result
}
// ── field primitives matching BotConfigEditor ─────────────────────────────────
interface FieldProps {
label: string
hint?: string
children: React.ReactNode
}
function F({ label, hint, children }: FieldProps) {
return (
<div className="flex flex-col gap-1">
<label className="text-xs text-muted font-medium">
{label}
{hint && (
<span className="opacity-60 font-normal">
{' '}
(
{hint}
)
</span>
)}
</label>
{children}
</div>
)
}
interface TextInputProps {
value: string | number
onChange: (v: string) => void
placeholder?: string
type?: string
}
function TI({ value, onChange, placeholder, type = 'text' }: TextInputProps) {
return (
<Input
className="font-mono"
type={type}
value={String(value)}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-label={placeholder ?? 'value'}
/>
)
}
interface CheckboxFieldProps {
label: string
checked: boolean
onChange: (v: boolean) => void
hint?: string
}
function CB({ label, checked, onChange, hint }: CheckboxFieldProps) {
return (
<div className="flex flex-col gap-1">
{hint && <p className="text-xs text-muted">{hint}</p>}
<div className="flex flex-1 items-center">
<Switch isSelected={!!checked} onChange={onChange} size="sm">
<Switch.Control><Switch.Thumb /></Switch.Control>
<Switch.Content>{label}</Switch.Content>
</Switch>
</div>
</div>
)
}
interface GridRowProps {
children: React.ReactNode
}
function G2({ children }: GridRowProps) {
return <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-1">{children}</div>
}
// ── main component ────────────────────────────────────────────────────────────
interface SettingsConfigFormProps {
saveRef?: MutableRefObject<(() => Promise<void>) | null>
onSavingChange?: (saving: boolean) => void
}
export const SettingsConfigForm: React.FC<SettingsConfigFormProps> = ({ saveRef, onSavingChange }) => {
const { t } = useTranslation()
const [cfg, setCfg] = useState<AppConfig>(EMPTY)
const [loading, setLoading] = useState(true)
const [tab, setTab] = useState('connection')
const [backendUrl, setBackendUrl] = useState(() => localStorage.getItem('dune_admin_backend') || '')
useEffect(() => {
api.config.get()
.then((c) => setCfg(mergeConfig(c as Record<string, unknown>)))
.catch((e) => toast.danger(t('settings.loadFailed', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setLoading(false))
}, [t])
const set = (key: keyof AppConfig) => (v: string) =>
setCfg((prev) => ({
...prev,
[key]: key === 'db_port' || key === 'scrip_currency' || key === 'market_bot_max_buys' || key === 'amp_api_port'
? (Number(v) || 0)
: key === 'market_bot_buy_threshold'
? (parseFloat(v) || 0)
: v,
}))
const setBool = (key: keyof AppConfig) => (v: boolean) =>
setCfg((prev) => ({ ...prev, [key]: v }))
const save = async () => {
onSavingChange?.(true)
try {
await api.config.save(cfg)
toast.success(t('settings.configSaved'))
}
catch (e: unknown) {
toast.danger(t('settings.saveFailed', { message: e instanceof Error ? e.message : String(e) }))
}
finally {
onSavingChange?.(false)
}
}
// Expose save to the parent footer button only after config has loaded.
// Clear the ref on unmount so a stale closure from a previous modal open
// cannot fire after the form has been removed from the tree.
useEffect(() => {
if (saveRef && !loading) {
saveRef.current = save
return () => {
saveRef.current = null
}
}
})
if (loading) {
return (
<div className="flex items-center justify-center flex-1 gap-2 text-muted">
<Spinner size="sm" color="current" />
<span className="text-sm">{t('settings.loadingConfig')}</span>
</div>
)
}
const isKubectl = cfg.control === 'kubectl'
const isDocker = cfg.control === 'docker'
const isLocal = cfg.control === 'local'
const isAmp = cfg.control === 'amp'
return (
// Outer flex col: tabs + single save bar below
<div className="flex flex-col flex-1 min-h-0 gap-0">
<Tabs
selectedKey={tab}
onSelectionChange={(k) => setTab(String(k))}
className="flex flex-col flex-1 min-h-0"
>
{/* Tab bar — never scrolls */}
<Tabs.ListContainer className="shrink-0">
<Tabs.List aria-label="Config sections" className="gap-1">
<Tabs.Tab id="connection">
{t('settings.tabs.connection')}
<Tabs.Indicator />
</Tabs.Tab>
<Tabs.Tab id="control">
{t('settings.tabs.control')}
<Tabs.Indicator />
</Tabs.Tab>
<Tabs.Tab id="broker">
{t('settings.tabs.broker')}
<Tabs.Indicator />
</Tabs.Tab>
<Tabs.Tab id="advanced">
{t('settings.tabs.advanced')}
<Tabs.Indicator />
</Tabs.Tab>
</Tabs.List>
</Tabs.ListContainer>
{/* ── Connection ─────────────────────────────────────────────────── */}
<Tabs.Panel id="connection" className="pt-4 overflow-y-auto flex-1 pr-1 flex flex-col gap-4">
<Panel>
<SectionLabel>{t('settings.sections.database')}</SectionLabel>
<G2>
<F label={t('settings.db.host')} hint={t('settings.db.hostHint')}>
<TI value={cfg.db_host} onChange={set('db_host')} placeholder="127.0.0.1" />
</F>
<F label={t('settings.db.port')}>
<NumberInput
ariaLabel={t('settings.db.port')}
value={Number(cfg.db_port) || 0}
onChange={(v) => set('db_port')(String(v))}
showButtons={false}
className="w-full"
/>
</F>
<F label={t('settings.db.user')}>
<TI value={cfg.db_user} onChange={set('db_user')} placeholder="dune" />
</F>
<F label={t('settings.db.password')} hint={t('settings.db.passwordHint')}>
<TI value={cfg.db_pass} onChange={set('db_pass')} type="password" placeholder={MASKED} />
</F>
<F label={t('settings.db.name')}>
<TI value={cfg.db_name} onChange={set('db_name')} placeholder="dune" />
</F>
<F label={t('settings.db.schema')}>
<TI value={cfg.db_schema} onChange={set('db_schema')} placeholder="dune" />
</F>
</G2>
</Panel>
<Panel>
<SectionLabel>{t('settings.sections.ssh')}</SectionLabel>
<G2>
<F label={t('settings.ssh.hostPort')} hint={t('settings.ssh.hostPortHint')}>
<TI value={cfg.ssh_host} onChange={set('ssh_host')} placeholder="192.168.0.72:22" />
</F>
<F label={t('settings.ssh.user')}>
<TI value={cfg.ssh_user} onChange={set('ssh_user')} placeholder="dune" />
</F>
<F label={t('settings.ssh.privateKey')} hint={t('settings.ssh.privateKeyHint')}>
<TI value={cfg.ssh_key} onChange={set('ssh_key')} placeholder="~/.ssh/id_ed25519" />
</F>
</G2>
</Panel>
</Tabs.Panel>
{/* ── Control ────────────────────────────────────────────────────── */}
<Tabs.Panel id="control" className="pt-4 overflow-y-auto flex-1 pr-1 flex flex-col gap-4">
<Panel>
<SectionLabel>{t('settings.sections.controlPlane')}</SectionLabel>
<div className="mt-1 flex flex-col gap-1">
<Select
selectedKey={cfg.control || 'local'}
onSelectionChange={(k) => setCfg((prev) => ({ ...prev, control: String(k) }))}
className="w-64"
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
<ListBox.Item id="kubectl" textValue="kubectl">
{t('settings.control.kubectl')}
<ListBox.ItemIndicator />
</ListBox.Item>
<ListBox.Item id="docker" textValue="docker">
{t('settings.control.docker')}
<ListBox.ItemIndicator />
</ListBox.Item>
<ListBox.Item id="local" textValue="local">
{t('settings.control.local')}
<ListBox.ItemIndicator />
</ListBox.Item>
<ListBox.Item id="amp" textValue="amp">
{t('settings.control.amp')}
<ListBox.ItemIndicator />
</ListBox.Item>
</ListBox>
</Select.Popover>
</Select>
<p className="text-xs text-muted">{t('settings.control.modeHint')}</p>
</div>
</Panel>
{isKubectl && (
<Panel>
<SectionLabel>{t('settings.sections.kubernetes')}</SectionLabel>
<G2>
<F label={t('settings.k8s.namespace')} hint={t('settings.k8s.namespaceHint')}>
<TI value={cfg.control_namespace} onChange={set('control_namespace')} placeholder="my-namespace" />
</F>
</G2>
</Panel>
)}
{isDocker && (
<Panel>
<SectionLabel>{t('settings.sections.dockerContainers')}</SectionLabel>
<G2>
<F label={t('settings.docker.gameServer')}><TI value={cfg.docker_gameserver} onChange={set('docker_gameserver')} placeholder="dune-gameserver" /></F>
<F label={t('settings.docker.brokerGame')}><TI value={cfg.docker_broker_game} onChange={set('docker_broker_game')} placeholder="dune-mq-game" /></F>
<F label={t('settings.docker.brokerAdmin')}><TI value={cfg.docker_broker_admin} onChange={set('docker_broker_admin')} placeholder="dune-mq-admin" /></F>
<F label={t('settings.docker.database')}><TI value={cfg.docker_db} onChange={set('docker_db')} placeholder="dune-postgres" /></F>
</G2>
</Panel>
)}
{isLocal && (
<Panel>
<SectionLabel>{t('settings.sections.serverCommands')}</SectionLabel>
<G2>
<F label={t('settings.cmd.start')}><TI value={cfg.cmd_start} onChange={set('cmd_start')} placeholder="service dune start" /></F>
<F label={t('settings.cmd.stop')}><TI value={cfg.cmd_stop} onChange={set('cmd_stop')} placeholder="service dune stop" /></F>
<F label={t('settings.cmd.restart')}><TI value={cfg.cmd_restart} onChange={set('cmd_restart')} placeholder="service dune restart" /></F>
<F label={t('settings.cmd.status')}><TI value={cfg.cmd_status} onChange={set('cmd_status')} placeholder="service dune status" /></F>
</G2>
</Panel>
)}
{isAmp && (
<Panel>
<SectionLabel>{t('settings.sections.amp')}</SectionLabel>
<G2>
<F label={t('settings.amp.instanceName')}><TI value={cfg.amp_instance} onChange={set('amp_instance')} placeholder="DuneAwakening01" /></F>
<F label={t('settings.amp.containerName')} hint={t('settings.amp.containerNameHint')}><TI value={cfg.amp_container} onChange={set('amp_container')} placeholder="AMP_DuneAwakening01" /></F>
<F label={t('settings.amp.user')}><TI value={cfg.amp_user} onChange={set('amp_user')} placeholder="amp" /></F>
<F label={t('settings.amp.logPath')}><TI value={cfg.amp_log_path} onChange={set('amp_log_path')} placeholder="/logs" /></F>
<F label={t('settings.amp.dataRoot')}><TI value={cfg.amp_data_root} onChange={set('amp_data_root')} placeholder="/AMP/duneawakening" /></F>
<CB
label={t('settings.amp.useContainer')}
checked={cfg.amp_use_container}
onChange={setBool('amp_use_container')}
hint={t('settings.amp.useContainerHint')}
/>
</G2>
<p className="text-xs text-muted mt-3">{t('settings.amp.apiHint')}</p>
<G2>
<F label={t('settings.amp.apiUser')}><TI value={cfg.amp_api_user} onChange={set('amp_api_user')} placeholder="admin" /></F>
<F label={t('settings.amp.apiPassword')}><TI value={cfg.amp_api_pass} onChange={set('amp_api_pass')} type="password" placeholder={MASKED} /></F>
<F label={t('settings.amp.apiPort')} hint="default 8081">
<NumberInput
ariaLabel={t('settings.amp.apiPort')}
value={Number(cfg.amp_api_port) || 0}
onChange={(v) => set('amp_api_port')(String(v))}
showButtons={false}
className="w-full"
/>
</F>
</G2>
</Panel>
)}
{!isKubectl && !isDocker && !isLocal && !isAmp && (
<p className="text-xs text-muted pt-2">{t('settings.control.selectMode')}</p>
)}
</Tabs.Panel>
{/* ── Broker ─────────────────────────────────────────────────────── */}
<Tabs.Panel id="broker" className="pt-4 overflow-y-auto flex-1 pr-1 flex flex-col gap-4">
<Panel>
<SectionLabel>{t('settings.sections.rabbitmq')}</SectionLabel>
<p className="text-xs text-muted -mt-1">{t('settings.broker.optionalHint')}</p>
<G2>
<F label={t('settings.broker.gameAddr')}><TI value={cfg.broker_game_addr} onChange={set('broker_game_addr')} placeholder="10.x.x.x:5672" /></F>
<F label={t('settings.broker.adminAddr')}><TI value={cfg.broker_admin_addr} onChange={set('broker_admin_addr')} placeholder="10.x.x.x:5672" /></F>
<F label={t('settings.broker.user')}><TI value={cfg.broker_user} onChange={set('broker_user')} placeholder="dune_cap" /></F>
<F label={t('settings.broker.password')}><TI value={cfg.broker_pass} onChange={set('broker_pass')} type="password" placeholder={MASKED} /></F>
<F label={t('settings.broker.jwtSecret')} hint={t('settings.broker.jwtSecretHint')}>
<TI value={cfg.broker_jwt_secret} onChange={set('broker_jwt_secret')} type="password" placeholder={MASKED} />
</F>
<F label={t('settings.broker.execPrefix')} hint={t('settings.broker.execPrefixHint')}>
<TI value={cfg.broker_exec_prefix} onChange={set('broker_exec_prefix')} placeholder="podman exec <container>" />
</F>
<div className="sm:col-span-2">
<CB label={t('settings.broker.useTls')} checked={cfg.broker_tls} onChange={setBool('broker_tls')} />
</div>
</G2>
</Panel>
</Tabs.Panel>
{/* ── Advanced ───────────────────────────────────────────────────── */}
<Tabs.Panel id="advanced" className="pt-4 overflow-y-auto flex-1 pr-1 flex flex-col gap-4">
<Panel>
<SectionLabel>{t('settings.sections.server')}</SectionLabel>
<G2>
<F label={t('settings.adv.listenAddr')} hint={t('settings.adv.listenAddrHint')}>
<TI value={cfg.listen_addr} onChange={set('listen_addr')} placeholder=":8080" />
</F>
<F label={t('settings.adv.scripCurrency')}>
<NumberInput
ariaLabel={t('settings.adv.scripCurrency')}
value={Number(cfg.scrip_currency) || 0}
onChange={(v) => set('scrip_currency')(String(v))}
showButtons={false}
className="w-full"
/>
</F>
<F label={t('settings.adv.directorUrl')} hint={t('settings.adv.directorUrlHint')}>
<TI value={cfg.director_url} onChange={set('director_url')} placeholder="http://127.0.0.1:11717" />
</F>
</G2>
</Panel>
<Panel>
<SectionLabel>{t('settings.sections.paths')}</SectionLabel>
<G2>
<F label={t('settings.adv.backupDir')}>
<TI value={cfg.backup_dir} onChange={set('backup_dir')} placeholder="/path/to/backups" />
</F>
<F label={t('settings.adv.serverIniDir')} hint={t('settings.adv.serverIniDirHint')}>
<TI value={cfg.server_ini_dir} onChange={set('server_ini_dir')} placeholder="/path/to/server/state" />
</F>
<F label={t('settings.adv.defaultIniDir')} hint={t('settings.adv.defaultIniDirHint')}>
<TI value={cfg.default_ini_dir} onChange={set('default_ini_dir')} placeholder="/path/to/game/Config" />
</F>
</G2>
</Panel>
<Panel>
<SectionLabel>{t('settings.sections.backendUrlOverride')}</SectionLabel>
<p className="text-xs text-muted -mt-1">
{t('settings.adv.backendUrlHint')}
</p>
<G2>
<F label={t('settings.adv.url')} hint={t('settings.adv.urlHint')}>
<TI
value={backendUrl}
onChange={(v) => {
setBackendUrl(v)
localStorage.setItem('dune_admin_backend', v)
}}
placeholder="http://host:port"
/>
</F>
</G2>
<div className="flex gap-2 mt-1">
<Button size="sm" onPress={() => window.location.reload()}>{t('settings.adv.applyReload')}</Button>
<Button
size="sm"
variant="outline"
onPress={() => {
setBackendUrl('')
localStorage.removeItem('dune_admin_backend')
window.location.reload()
}}
>
{t('settings.adv.reset')}
</Button>
</div>
</Panel>
</Tabs.Panel>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,15 @@
import type React from 'react'
import type { SortDir } from '../hooks/useTableSort'
interface SortIndicatorProps {
active: boolean
dir: SortDir
}
export const SortIndicator: React.FC<SortIndicatorProps> = ({ active, dir }) => {
return (
<span style={{ marginLeft: 4, opacity: active ? 1 : 0.25 }}>
{active ? (dir === 'asc' ? '▲' : '▼') : '▲'}
</span>
)
}

View File

@@ -0,0 +1,50 @@
import type React from 'react'
import { useState } from 'react'
import { Dropdown, Button } from '@heroui/react'
import { useTranslation } from 'react-i18next'
import { Icon } from '../dune-ui'
import { THEMES, applyTheme, loadTheme, type ThemeId } from '../theme'
export const ThemeSelector: React.FC = () => {
const { t } = useTranslation()
const [current, setCurrent] = useState<ThemeId>(loadTheme)
return (
<Dropdown>
<Button
isIconOnly
variant="ghost"
size="sm"
aria-label={t('app.selectTheme')}
className="w-8 h-8 min-w-0 text-muted data-[hover=true]:text-foreground data-[hover=true]:bg-surface-secondary"
>
<Icon name="palette" />
</Button>
<Dropdown.Popover>
<Dropdown.Menu
aria-label={t('app.selectTheme')}
selectionMode="single"
selectedKeys={new Set([current])}
onSelectionChange={(keys) => {
if (keys === 'all') return
const id = [...keys][0] as ThemeId
if (id) {
applyTheme(id)
setCurrent(id)
}
}}
>
{THEMES.map((th) => (
<Dropdown.Item key={th.id} id={th.id} textValue={th.label}>
<span className="flex items-center gap-2">
{/* Literal color sample — intentional, not a themed element */}
<span className="w-3 h-3 rounded-full border border-border shrink-0" style={{ background: th.swatch }} />
<span>{th.label}</span>
</span>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
)
}

View File

@@ -0,0 +1,98 @@
import type React from 'react'
import { useState, useMemo } from 'react'
import { SearchField } from '@heroui/react'
import { useTranslation } from 'react-i18next'
// IANA timezone names from the browser when available (Chrome 99+/modern), with
// a small fallback for older runtimes. Computed once at module load.
function tzList(): string[] {
const fn = (Intl as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf
try {
if (typeof fn === 'function') return fn('timeZone')
}
catch { /* fall through to fallback */ }
return [
'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
'America/Sao_Paulo', 'Europe/London', 'Europe/Berlin', 'Europe/Paris', 'Europe/Moscow',
'Asia/Tokyo', 'Asia/Shanghai', 'Asia/Kolkata', 'Australia/Sydney',
]
}
const ZONES = tzList()
const MAX_VISIBLE = 60
// When closed, displayValue is derived from value prop — no local state needed.
// When open, query drives the filter and the SearchField input.
export const TimezoneSelect: React.FC<{
value: string
onChange: (v: string) => void
className?: string
}> = ({ value, onChange, className }) => {
const { t } = useTranslation()
const hostLabel = t('common.tzHostLocal')
const allOptions = useMemo(
() => [{ key: '', label: hostLabel }, ...ZONES.map((z) => ({ key: z, label: z }))],
[hostLabel],
)
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
// While closed, show the settled value; while open, show what the user is typing.
const displayValue = open ? query : (value === '' ? hostLabel : value)
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return allOptions.slice(0, MAX_VISIBLE)
return allOptions.filter(({ label }) => label.toLowerCase().includes(q)).slice(0, MAX_VISIBLE)
}, [query, allOptions])
const pick = (key: string) => {
onChange(key)
setOpen(false)
}
const handleFocus = () => {
setQuery(value === '' ? hostLabel : value)
setOpen(true)
}
const handleChange = (v: string) => {
setQuery(v)
setOpen(true)
}
return (
<div className={`relative ${className ?? ''}`}>
<SearchField
className="w-full"
value={displayValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={() => setTimeout(() => setOpen(false), 150)}
aria-label={t('common.timezone')}
>
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input placeholder={hostLabel} />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
{open && filtered.length > 0 && (
<div className="absolute z-50 w-full mt-1 rounded-[var(--radius)] border border-border bg-surface shadow-lg overflow-y-auto max-h-52">
{filtered.map(({ key, label }) => (
<div
key={key || '__host__'}
onMouseDown={(e) => e.preventDefault()}
onClick={() => pick(key)}
className={`px-3 py-1.5 text-xs cursor-pointer hover:bg-surface-hover${key === value ? ' text-accent font-medium' : ''}`}
>
{label}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,8 @@
/**
* Compatibility shim — re-exports the item-data API from store.ts so existing
* import sites (MarketTab/ItemDetail, GiveItemsModal) continue to work without
* modification.
*/
export type { ItemEntry, ItemDataFile } from './store'
export { cdnBase, getItemData, getItemEntry } from './store'

View File

@@ -0,0 +1,200 @@
/**
* Unified data store — fetches static JSON files from the Go backend first
* (enabling local overrides), falls back to the CDN when Go returns 404 or
* when running on a pure CDN deploy with no backend reachable.
*
* All atoms use jotai's implicit default store so cache persists until a hard
* refresh. Components use useAtom(loadable(atomRef)); imperative callers use
* getDefaultStore().get(atomRef) via the helper functions below.
*/
import { atom, getDefaultStore } from 'jotai'
import { loadable } from 'jotai/utils'
import { useAtom } from 'jotai'
import type { Atom } from 'jotai'
import { apiBase, isCdnDeploy } from '../api/client'
// ── CDN base ──────────────────────────────────────────────────────────────────
/** Returns the CDN base URL, stripped of trailing slashes. */
export const cdnBase = (): string =>
((import.meta.env.VITE_CDN_BASE_URL as string) ?? 'https://assets.dune.layout.tools').replace(/\/$/, '')
// ── Fetch primitive ───────────────────────────────────────────────────────────
/**
* Fetches a named data file. On non-CDN deploys the Go backend is tried first
* (allows local overrides); a non-ok response or network error causes
* transparent fallback to the CDN. Throws only when both sources fail.
*/
async function fetchDataFile<T>(filename: string): Promise<T> {
if (!isCdnDeploy) {
try {
const res = await fetch(`${apiBase}/data/${filename}`)
if (res.ok) return res.json() as Promise<T>
}
catch {
// fall through to CDN
}
}
const res = await fetch(`${cdnBase()}/${filename}`)
if (!res.ok) throw new Error(`Failed to load ${filename}`)
return res.json() as Promise<T>
}
// ── Types ─────────────────────────────────────────────────────────────────────
export type ItemEntry = {
name?: string
category?: string
tier?: number
rarity?: string
is_gradeable?: boolean
armor_value?: number
mitigation?: Record<string, number>
}
export type ItemDataFile = {
default_stack_max?: number
default_volume?: number
names?: Record<string, string>
items: Record<string, ItemEntry>
}
export type QualityData = {
weapon_damage: number[]
armor: number[]
volume: number[]
}
export type SkillModule = {
id: string
label: string
}
export type Vehicle = {
id: string
label: string
actor_class: string
templates: string[]
}
export type CheatScript = {
name: string
danger: boolean
}
export type PacksData = {
packs: Record<string, {
name: string
category: string
tier: number
items: { template: string, qty: number, quality: number }[]
}>
}
// ── Atoms ─────────────────────────────────────────────────────────────────────
export const itemDataAtom = atom<Promise<ItemDataFile>>(async () => {
try {
return await fetchDataFile<ItemDataFile>('item-data.json')
}
catch {
return { items: {} }
}
})
export const tagsDataAtom = atom<Promise<unknown>>(async () => {
try {
return await fetchDataFile<unknown>('tags-data.json')
}
catch {
return {}
}
})
export const qualityDataAtom = atom<Promise<QualityData>>(async () => {
try {
return await fetchDataFile<QualityData>('quality-data.json')
}
catch {
return { weapon_damage: [], armor: [], volume: [] }
}
})
export const gameplayTagsAtom = atom<Promise<string[]>>(async () => {
try {
return await fetchDataFile<string[]>('gameplayTags.json')
}
catch {
return []
}
})
export const skillModulesAtom = atom<Promise<SkillModule[]>>(async () => {
try {
return await fetchDataFile<SkillModule[]>('skillModules.json')
}
catch {
return []
}
})
export const vehiclesAtom = atom<Promise<Vehicle[]>>(async () => {
try {
return await fetchDataFile<Vehicle[]>('vehicles.json')
}
catch {
return []
}
})
export const cheatScriptsAtom = atom<Promise<CheatScript[]>>(async () => {
try {
return await fetchDataFile<CheatScript[]>('cheatScripts.json')
}
catch {
return []
}
})
export const packsAtom = atom<Promise<PacksData>>(async () => {
try {
return await fetchDataFile<PacksData>('packs.json')
}
catch {
return { packs: {} }
}
})
// ── React hook ────────────────────────────────────────────────────────────────
/** Wraps a data atom with loadable so components can read it without Suspense. */
export function useDataFile<T>(fileAtom: Atom<Promise<T>>): {
data: T | null
loading: boolean
error: string | null
} {
const [state] = useAtom(loadable(fileAtom))
switch (state.state) {
case 'loading':
return { data: null, loading: true, error: null }
case 'hasError':
return { data: null, loading: false, error: String(state.error) }
default:
return { data: state.data, loading: false, error: null }
}
}
// ── Imperative accessors (non-React call sites) ───────────────────────────────
/** Returns a Promise resolving to the full item data. Shares the jotai cache. */
export function getItemData(): Promise<ItemDataFile> {
return getDefaultStore().get(itemDataAtom)
}
/** Returns the ItemEntry for the given template ID, or null if not found. */
export async function getItemEntry(templateId: string): Promise<ItemEntry | null> {
const data = await getItemData()
return data.items[templateId] ?? null
}

View File

@@ -0,0 +1,42 @@
import type React from 'react'
import { useTranslation } from 'react-i18next'
import { AlertDialog, Button } from '@heroui/react'
type ConfirmDialogProps = {
open: boolean
title: string
description: string
confirmLabel?: string
onConfirm: () => void
onCancel: () => void
}
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
open,
title,
description,
confirmLabel,
onConfirm,
onCancel,
}) => {
const { t } = useTranslation()
return (
<AlertDialog.Backdrop isOpen={open} onOpenChange={(v) => !v && onCancel()}>
<AlertDialog.Container size="sm">
<AlertDialog.Dialog>
<AlertDialog.Header>
<AlertDialog.Icon status="danger" />
<AlertDialog.Heading>{title}</AlertDialog.Heading>
</AlertDialog.Header>
<AlertDialog.Body>
<p className="text-sm text-muted">{description}</p>
</AlertDialog.Body>
<AlertDialog.Footer>
<Button slot="close" variant="ghost" onPress={onCancel}>{t('common.cancel')}</Button>
<Button slot="close" variant="danger-soft" onPress={onConfirm}>{confirmLabel ?? t('common.confirm')}</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
</AlertDialog.Container>
</AlertDialog.Backdrop>
)
}

View File

@@ -0,0 +1,203 @@
import { useState, useMemo, type ReactNode } from 'react'
import { Skeleton, Table, TableLayout, Virtualizer } from '@heroui/react'
import type { SortDescriptor } from '@heroui/react'
import { Icon } from './Icon'
export type Column<K extends string> = {
key: K
label: string
/** Whether this column is sortable. Defaults to true. */
sortable?: boolean
/** Marks the row-header column. Typically the first one. */
isRowHeader?: boolean
/** Fixed column width (px). When omitted, the column takes remaining space. */
width?: number
/** Minimum width (px). Useful with `width` omitted for the stretchy column. */
minWidth?: number
}
type ColumnRenderProps = { sortDirection?: 'ascending' | 'descending' }
type DataTableProps<T, K extends string> = {
/** Accessibility label, required by React Aria. */
'aria-label': string
'columns': Column<K>[]
'rows': T[]
/** Stable id extractor for each row. */
'rowId': (row: T) => string
/** Render the cell content for a given row + column key. */
'renderCell': (row: T, key: K) => ReactNode
/** Initial sort column + direction. */
'initialSort'?: { column: K, direction: 'ascending' | 'descending' }
/** Custom value getter for sorting (defaults to renderCell-as-string). */
'sortValue'?: (row: T, key: K) => string | number | null | undefined
/** Rendered when `rows` is empty. */
'emptyState'?: ReactNode
/** Shows skeleton rows instead of data while true. */
'loading'?: boolean
/** Number of skeleton rows to show while loading. Defaults to 5. */
'skeletonRows'?: number
/** Called when a row is clicked / activated. */
'onRowAction'?: (row: T) => void
/** Extra classes for the outer Table element. */
'className'?: string
/**
* Opt into HeroUI's TableLayout virtualizer. Set when row count can be
* large (>200). Only renders rows in the viewport; massive speedup for
* filter typing on large datasets. Requires `rowHeight` to be the actual
* rendered row height in px (default 32 matches our compact density).
*
* NOTE: virtualization requires row type `T` to be an **object** (React
* Aria stores items in a WeakMap keyed by the row). Don't enable this if
* your rows are primitives (strings/numbers).
*/
'virtualized'?: boolean
'rowHeight'?: number
}
/**
* Opinionated HeroUI Table wrapper with built-in sort, consistent compact
* styling (from global CSS), optional virtualization, and a column-driven
* API so callers don't have to type out the Table.* compound tree by hand.
*/
export const DataTable = <T, K extends string>({
'aria-label': ariaLabel,
columns,
rows,
rowId,
renderCell,
initialSort,
sortValue,
emptyState,
loading = false,
skeletonRows = 5,
onRowAction,
className,
virtualized = false,
rowHeight = 32,
}: DataTableProps<T, K>) => {
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>(
initialSort ?? { column: columns[0].key, direction: 'ascending' },
)
// React Aria requires at least one column with isRowHeader=true. If no
// caller-supplied column has it, promote the first column.
const cols = useMemo<Column<K>[]>(() => {
if (columns.some((c) => c.isRowHeader)) return columns
return columns.map((c, i) => i === 0 ? { ...c, isRowHeader: true } : c)
}, [columns])
const sorted = useMemo(() => {
const col = sortDescriptor.column as K
const dir = sortDescriptor.direction === 'descending' ? -1 : 1
const get = sortValue ?? ((row: T, key: K) => String(renderCell(row, key)))
return [...rows].sort((a, b) => {
const av = get(a, col)
const bv = get(b, col)
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir
return String(av ?? '').localeCompare(String(bv ?? ''), undefined, { numeric: true }) * dir
})
}, [rows, sortDescriptor, sortValue, renderCell])
const tableJSX = (
<Table className={`bg-transparent border-0 p-0 ${className ?? ''}`}>
<Table.ScrollContainer className="p-0 border border-border/60 rounded-md">
<Table.Content
aria-label={ariaLabel}
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
{...(!loading && onRowAction
? {
onRowAction: (key) => {
const row = sorted.find((r) => rowId(r) === String(key))
if (row) onRowAction(row)
},
}
: {})}
>
<Table.Header columns={cols}>
{(col: Column<K>) => {
const sortable = col.sortable !== false && !loading
return (
<Table.Column
id={col.key}
allowsSorting={sortable}
{...(col.isRowHeader ? { isRowHeader: true } : {})}
{...(col.width !== undefined ? { width: col.width } : {})}
{...(col.minWidth !== undefined ? { minWidth: col.minWidth } : {})}
>
{({ sortDirection }: ColumnRenderProps) => (
<span className="flex items-center gap-1">
<span className="flex-1 truncate">{col.label}</span>
{sortable && (
<Icon
name={
sortDirection === 'ascending'
? 'chevron-up'
: sortDirection === 'descending'
? 'chevron-down'
: 'chevrons-up-down'
}
className={'size-3 shrink-0 ' + (sortDirection ? '' : 'opacity-30')}
/>
)}
</span>
)}
</Table.Column>
)
}}
</Table.Header>
{loading
? (
<Table.Body>
{Array.from({ length: skeletonRows }, (_, i) => (
<Table.Row key={`skeleton-${i}`} id={`skeleton-${i}`}>
{cols.map((c) => (
<Table.Cell key={c.key}>
<Skeleton className="h-3 w-full rounded" />
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
)
: virtualized
? (
<Table.Body
items={sorted as unknown as object[]}
renderEmptyState={emptyState ? () => <>{emptyState}</> : undefined}
>
{((row: T) => (
<Table.Row id={rowId(row)}>
{cols.map((c) => (
<Table.Cell key={c.key}>{renderCell(row, c.key)}</Table.Cell>
))}
</Table.Row>
)) as unknown as (item: object) => ReactNode}
</Table.Body>
)
: (
<Table.Body
renderEmptyState={emptyState ? () => <>{emptyState}</> : undefined}
>
{sorted.map((row) => (
<Table.Row key={rowId(row)} id={rowId(row)}>
{cols.map((c) => (
<Table.Cell key={c.key}>{renderCell(row, c.key)}</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
)}
</Table.Content>
</Table.ScrollContainer>
</Table>
)
if (!virtualized) return tableJSX
return (
<Virtualizer layout={TableLayout} layoutOptions={{ headingHeight: rowHeight, rowHeight }}>
{tableJSX}
</Virtualizer>
)
}

View File

@@ -0,0 +1,115 @@
import type React from 'react'
import { useState, type ReactNode } from 'react'
import { Spinner, toast } from '@heroui/react'
import { Icon } from './Icon'
type DropzoneProps = {
/** Comma-separated list of accepted file extensions, e.g. ".json" or ".backup,.zip". */
accept: string
/** Called with the chosen file (drag-drop or click-to-pick). */
onSelect: (file: File) => void
/** Show this file's name + size as a "selected" state inside the dropzone. */
file?: File | null
/** Override the default prompt text shown when nothing is selected. */
prompt?: ReactNode
/** Spinner overlay — drive from parent state when an upload is in flight. */
uploading?: boolean
/** Compact (less vertical padding). */
compact?: boolean
className?: string
}
/**
* Drag-and-drop file picker. Click to open native file dialog; drop a file
* to select it. Validates the extension against `accept` and toasts on
* mismatch. Used by BlueprintsTab Import and BattlegroupTab Restore.
*/
export const Dropzone: React.FC<DropzoneProps> = ({
accept, onSelect, file, prompt, uploading, compact, className = '',
}) => {
const [dragging, setDragging] = useState(false)
const validateAndSelect = (f: File | undefined | null) => {
if (!f) return
const exts = accept.split(',').map((x) => x.trim().toLowerCase()).filter(Boolean)
if (exts.length > 0) {
const ok = exts.some((ext) => f.name.toLowerCase().endsWith(ext))
if (!ok) {
toast.warning(`Drop a ${accept} file`)
return
}
}
onSelect(f)
}
const openPicker = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = accept
input.onchange = () => validateAndSelect(input.files?.[0])
input.click()
}
return (
<div
className={
'rounded-[var(--radius)] flex flex-col items-center justify-center gap-2 text-sm cursor-pointer transition-all border-2 border-dashed '
+ (compact ? 'py-2 px-3' : 'py-6 px-4') + ' '
+ (dragging
? 'border-warning bg-warning/10 text-warning'
: 'border-border bg-background text-muted hover:border-warning/60 hover:text-warning')
+ ' ' + className
}
onDragOver={(e) => {
e.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
validateAndSelect(e.dataTransfer.files[0])
}}
onClick={openPicker}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
openPicker()
}
}}
>
{uploading
? (
<span className="flex items-center gap-2">
<Spinner size="sm" color="current" />
{' '}
Uploading
</span>
)
: file
? (
<span className="flex flex-col items-center gap-0.5">
<span className="flex items-center gap-1.5 text-foreground">
<Icon name="file-check" />
{' '}
<span className="font-mono">{file.name}</span>
</span>
<span className="text-xs text-muted">
{(file.size / 1024).toFixed(1)}
{' '}
KB · click to replace
</span>
</span>
)
: (
<span className="flex items-center gap-1.5">
<Icon name="upload" />
{' '}
{prompt ?? `Drop or click to upload a ${accept} file`}
</span>
)}
</div>
)
}

View File

@@ -0,0 +1,32 @@
import type React from 'react'
import { Input } from '@heroui/react'
interface FieldInputProps {
value: string
onChange: (v: string) => void
placeholder?: string
type?: 'text' | 'number' | 'password' | 'email' | 'url'
className?: string
ariaLabel?: string
isDisabled?: boolean
}
export const FieldInput: React.FC<FieldInputProps> = ({
value,
onChange,
placeholder,
type = 'text',
className,
ariaLabel,
isDisabled,
}) => (
<Input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-label={ariaLabel}
disabled={isDisabled}
className={className}
/>
)

View File

@@ -0,0 +1,45 @@
import type React from 'react'
import { Select, ListBox } from '@heroui/react'
interface FieldSelectProps {
value: string
onChange: (v: string) => void
options: string[]
className?: string
ariaLabel?: string
isDisabled?: boolean
}
// FieldSelect wraps HeroUI Select + ListBox for small, fixed option sets.
// For large lists (e.g. 400 IANA timezones), keep native <select> for type-to-search.
export const FieldSelect: React.FC<FieldSelectProps> = ({
value,
onChange,
options,
className,
ariaLabel,
isDisabled,
}) => (
<Select
selectedKey={value}
onSelectionChange={(k) => onChange(String(k))}
aria-label={ariaLabel}
isDisabled={isDisabled}
className={className}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
{options.map((opt) => (
<ListBox.Item key={opt} id={opt} textValue={opt}>
{opt}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
)

View File

@@ -0,0 +1,18 @@
import type React from 'react'
import { Icon as IconifyIcon } from '@iconify/react'
type IconProps = {
/** Lucide icon name (without the `lucide:` prefix), e.g. "refresh-cw". */
name: string
/** Optional size class — defaults to `size-4` (1rem square). */
className?: string
}
/**
* Thin wrapper around `@iconify/react` that defaults to the lucide icon set
* and a sensible inline-text size. Use any lucide icon name from
* https://lucide.dev/icons (kebab-case).
*/
export const Icon: React.FC<IconProps> = ({ name, className = 'size-4' }) => (
<IconifyIcon icon={`lucide:${name}`} className={className} />
)

View File

@@ -0,0 +1,43 @@
import type React from 'react'
import type { ReactNode } from 'react'
type CardProps = { children: ReactNode, className?: string }
type ItemProps = {
label: ReactNode
value: ReactNode
/** Optional explicit value text color (e.g. phase status color). */
valueColor?: string
}
/**
* Bordered, slightly-elevated label/value row card — the "Phase Reconciling
* | Database Ready" health row pattern from BattlegroupTab.
*/
export const InfoCard: React.FC<CardProps> & { Item: React.FC<ItemProps> } = ({ children, className = '' }) => {
return (
<div
className={
'flex items-center gap-6 rounded-[var(--radius)] px-4 py-3 text-sm shrink-0 '
+ 'bg-surface border border-border/60 dune-lift '
+ className
}
>
{children}
</div>
)
}
export const InfoCardItem: React.FC<ItemProps> = ({ label, value, valueColor }) => {
return (
<div className="flex items-center gap-2">
<span className="text-muted">{label}</span>
<span className="font-semibold" style={valueColor ? { color: valueColor } : undefined}>
{value}
</span>
</div>
)
}
// Namespace alias kept for callers using <InfoCard.Item>
InfoCard.Item = InfoCardItem

View File

@@ -0,0 +1,26 @@
import type React from 'react'
import { Spinner } from '@heroui/react'
type LoadingStateProps = {
/** Vertical padding size. Defaults to 'lg' (py-12). */
size?: 'sm' | 'md' | 'lg'
/** Fill available height with flex-1 (use inside a flex column). */
fill?: boolean
className?: string
}
const PAD: Record<NonNullable<LoadingStateProps['size']>, string> = {
sm: 'py-4',
md: 'py-8',
lg: 'py-12',
}
/**
* Standard centered loading spinner. Use for full-tab / full-section loads so
* every tab shows the same loading treatment.
*/
export const LoadingState: React.FC<LoadingStateProps> = ({ size = 'lg', fill = false, className = '' }) => (
<div className={`flex justify-center ${PAD[size]} ${fill ? 'flex-1' : ''} ${className}`}>
<Spinner size="lg" />
</div>
)

View File

@@ -0,0 +1,70 @@
import type React from 'react'
import { Label, NumberField } from '@heroui/react'
interface NumberInputProps {
value: number
onChange: (value: number) => void
min?: number
max?: number
step?: number
label?: string
prefix?: string
ariaLabel?: string
isDisabled?: boolean
className?: string
showButtons?: boolean
formatOptions?: Intl.NumberFormatOptions
}
export const NumberInput: React.FC<NumberInputProps> = ({
value,
onChange,
min,
max,
step = 1,
label,
prefix,
ariaLabel,
isDisabled,
className,
showButtons = true,
formatOptions,
}) => {
const field = (
<NumberField
value={value}
onChange={(v) => onChange(v ?? min ?? 0)}
minValue={min}
maxValue={max}
step={step}
isDisabled={isDisabled}
aria-label={ariaLabel ?? label ?? prefix}
variant="secondary"
className={prefix ? 'flex-1 min-w-0' : className}
formatOptions={formatOptions}
>
{label && <Label className="text-xs text-muted">{label}</Label>}
<NumberField.Group
className="w-full"
style={prefix
? { width: '100%', display: 'flex', alignItems: 'center', borderTopLeftRadius: 0, borderBottomLeftRadius: 0, borderLeft: 'none' }
: { width: '100%', display: 'flex', alignItems: 'center' }}
>
{showButtons && <NumberField.DecrementButton />}
<NumberField.Input className="flex-1" style={{ flexGrow: 1, minWidth: 40 }} />
{showButtons && <NumberField.IncrementButton />}
</NumberField.Group>
</NumberField>
)
if (!prefix) return field
return (
<div className={`flex items-stretch ${className ?? ''}`}>
<span className="px-2 text-xs text-muted shrink-0 flex items-center border border-r-0 border-border rounded-l-[var(--radius)] bg-surface-secondary">
{prefix}
</span>
{field}
</div>
)
}

View File

@@ -0,0 +1,51 @@
import type React from 'react'
import type { ReactNode } from 'react'
import { Button, Spinner } from '@heroui/react'
import { Icon } from './Icon'
type PageHeaderProps = {
title: ReactNode
/** Optional descriptive subtitle below the title. */
subtitle?: ReactNode
/** When provided, a refresh button is rendered in the action slot. */
onRefresh?: () => void
/** Shows a spinner in the refresh button while true. */
loading?: boolean
/** Seconds until next auto-refresh — shown as a dim countdown beside "Refresh". */
countdown?: number
/** Additional action buttons / controls rendered on the right. */
children?: ReactNode
}
export const PageHeader: React.FC<PageHeaderProps> = ({ title, subtitle, onRefresh, loading, countdown, children }) => {
return (
<div className="flex items-start justify-between gap-3 shrink-0 border-b border-border/60 pb-3 mb-1">
<div className="flex-1 min-w-0">
<h2 className="text-base font-semibold text-accent truncate">{title}</h2>
{subtitle && <p className="text-sm text-muted mt-0.5">{subtitle}</p>}
</div>
{(onRefresh != null || children) && (
<div className="flex items-center gap-2 shrink-0">
{children}
{onRefresh != null && (
<Button size="sm" variant="ghost" onPress={onRefresh} isDisabled={loading}>
{loading
? <Spinner size="sm" color="current" />
: (
<>
{countdown != null && (
<span className="w-7 text-right tabular-nums text-muted/60 text-xs">
{countdown}
s
</span>
)}
<Icon name="refresh-cw" />
</>
)}
</Button>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,23 @@
import type React from 'react'
import type { ReactNode } from 'react'
type PanelProps = {
children: ReactNode
className?: string
}
/**
* Elevated bordered card. Use for content groups like the Progression Unlock
* sub-panels in PlayerActionsModal.
*/
export const Panel: React.FC<PanelProps> = ({ children, className = '' }) => (
<div
className={
'rounded-[var(--radius)] p-4 flex flex-col gap-2 '
+ 'bg-surface-secondary border border-border dune-lift '
+ className
}
>
{children}
</div>
)

View File

@@ -0,0 +1,20 @@
import type React from 'react'
import type { ReactNode } from 'react'
type SectionDividerProps = {
title: ReactNode
/** Optional action buttons rendered on the right side of the divider. */
children?: ReactNode
}
/**
* Amber section title with a top border + padding above to separate it from
* the preceding section. Matches the "Server Control" divider in
* BattlegroupTab.
*/
export const SectionDivider: React.FC<SectionDividerProps> = ({ title, children }) => (
<div className="flex items-center gap-3 border-t border-(--accent-soft-border)/30 pt-3 mt-3 shrink-0">
<h3 className="text-base font-semibold text-accent flex-1 border-l-2 border-(--accent-soft-border) pl-2">{title}</h3>
{children && <div className="flex items-center gap-2 shrink-0">{children}</div>}
</div>
)

View File

@@ -0,0 +1,18 @@
import type React from 'react'
import type { ReactNode } from 'react'
interface SectionLabelProps {
children: ReactNode
}
/**
* Small uppercase amber label — sub-section heading inside a Panel.
* Pairs with [[PageHeader]] (top-level) and [[SectionDivider]] (mid-level).
*/
export const SectionLabel: React.FC<SectionLabelProps> = ({ children }) => {
return (
<h4 className="text-xs font-semibold uppercase tracking-widest text-accent border-l-2 border-(--accent-soft-border) pl-2">
{children}
</h4>
)
}

View File

@@ -0,0 +1,78 @@
import type { ReactNode } from 'react'
type Item<K extends string> = {
key: K
label: ReactNode
/** Optional sub-label rendered below the main label (e.g. namespace, item count). */
sublabel?: ReactNode
/** Optional right-aligned hint (e.g. "18 items" count chip). */
hint?: ReactNode
/** Indentation level (0 = top-level, 1 = child item). */
depth?: number
}
type SideNavProps<K extends string> = {
items: Item<K>[]
active: K | null
onSelect: (key: K) => void
/** Header text shown above the list (e.g. "PODS", "CONTAINERS (70)"). */
title?: ReactNode
/** Action element rendered next to the title (e.g. a refresh button). */
titleAction?: ReactNode
/** Width of the side nav. Defaults to 240px (w-60). */
width?: string
children?: ReactNode
}
/**
* Reusable left side-navigation panel: bordered card with a title row +
* scrollable list of selectable items. Used by Players sidebar, Database
* section nav, Logs pod list, Storage container list, etc.
*
* Pass arbitrary `children` to render extra content (search inputs, info
* banners) between the title and the list.
*/
export const SideNav = <K extends string>({
items, active, onSelect, title, titleAction, width, children,
}: SideNavProps<K>) => {
const w = width ?? 'w-60'
return (
<div className={`${w} shrink-0 flex flex-col rounded-[var(--radius)] bg-surface border border-border/60 dune-lift overflow-hidden`}>
{(title || titleAction) && (
<div className="flex items-center justify-between px-3 py-2 border-b border-border/60 shrink-0 bg-gradient-to-b from-(--surface-secondary) to-transparent">
{title && <span className="text-xs font-semibold uppercase tracking-widest text-accent">{title}</span>}
{titleAction}
</div>
)}
{children && <div className="px-2 py-1.5 shrink-0">{children}</div>}
<div className="overflow-y-auto flex-1 flex flex-col gap-0.5 p-1">
{items.map((item) => {
const isActive = item.key === active
return (
<button
key={item.key}
onClick={() => onSelect(item.key)}
className={
'text-left rounded-[var(--radius)] text-sm transition-colors flex items-start gap-2 '
+ (item.depth ? 'pl-6 pr-3 py-1.5 ' : 'px-3 py-2 ')
+ (isActive
? 'bg-accent text-accent-foreground font-semibold'
: 'text-foreground hover:bg-surface-hover')
}
>
<div className="flex-1 min-w-0">
<div className="truncate">{item.label}</div>
{item.sublabel && (
<div className={'truncate text-xs ' + (isActive ? 'opacity-80' : 'text-muted')}>
{item.sublabel}
</div>
)}
</div>
{item.hint && <div className="shrink-0 text-xs">{item.hint}</div>}
</button>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
import type React from 'react'
import { Time } from '@internationalized/date'
import { TimeField, ToggleButton, ToggleButtonGroup } from '@heroui/react'
interface TimeInputProps {
value: string // "HH:MM" in 24h
onChange: (v: string) => void
ariaLabel?: string
className?: string
isDisabled?: boolean
}
function parseHHMM(s: string): Time | null {
const parts = s.split(':')
const h = Number(parts[0])
const m = Number(parts[1])
if (Number.isNaN(h) || Number.isNaN(m)) return null
return new Time(h, m)
}
function toHHMM(t: Time): string {
return `${String(t.hour).padStart(2, '0')}:${String(t.minute).padStart(2, '0')}`
}
export const TimeInput: React.FC<TimeInputProps> = ({ value, onChange, ariaLabel, className, isDisabled }) => {
const timeValue = parseHHMM(value)
const isAM = timeValue ? timeValue.hour < 12 : true
const handleTimeChange = (t: Time | null) => {
if (t) onChange(toHHMM(t))
}
const handlePeriodChange = (keys: Iterable<React.Key> | 'all') => {
if (!timeValue) return
const period = keys === 'all' ? null : [...keys][0]
if (!period) return
let { hour } = timeValue
const { minute } = timeValue
if (period === 'pm' && hour < 12) hour += 12
else if (period === 'am' && hour >= 12) hour -= 12
onChange(toHHMM(new Time(hour, minute)))
}
return (
<div className={`flex items-center gap-1 ${className ?? ''}`}>
<TimeField
value={timeValue}
onChange={handleTimeChange}
hourCycle={12}
granularity="minute"
aria-label={ariaLabel}
isDisabled={isDisabled}
>
<TimeField.Group variant="secondary">
<TimeField.Input>
{(segment) => (
<TimeField.Segment
segment={segment}
className={segment.type === 'dayPeriod' ? 'hidden' : ''}
/>
)}
</TimeField.Input>
</TimeField.Group>
</TimeField>
<ToggleButtonGroup
selectionMode="single"
disallowEmptySelection
selectedKeys={[isAM ? 'am' : 'pm']}
onSelectionChange={handlePeriodChange}
size="sm"
isDisabled={isDisabled}
>
<ToggleButton id="am">AM</ToggleButton>
<ToggleButton id="pm">PM</ToggleButton>
</ToggleButtonGroup>
</div>
)
}

View File

@@ -0,0 +1,9 @@
/**
* Pre-load the full lucide icon collection so `<Icon icon="lucide:..." />`
* works offline without runtime CDN fetches. Bundle cost: ~500KB gzipped.
* If that becomes a problem, switch to per-icon imports via `addIcon`.
*/
import { addCollection } from '@iconify/react'
import lucide from '@iconify-json/lucide/icons.json'
addCollection(lucide)

View File

@@ -0,0 +1,26 @@
/**
* Dune Admin component library — opinionated wrappers around HeroUI v3 that
* carry the project's amber/dark aesthetic. Import from here, not from
* @heroui/react directly, when there's an equivalent dune-ui wrapper.
*
* Side effect: importing this module registers the lucide icon collection
* with iconify so `<Icon name="..." />` works offline.
*/
import './icons'
export { Icon } from './Icon'
export { PageHeader } from './PageHeader'
export { InfoCard } from './InfoCard'
export { SectionDivider } from './SectionDivider'
export { SectionLabel } from './SectionLabel'
export { Panel } from './Panel'
export { LoadingState } from './LoadingState'
export { DataTable } from './DataTable'
export type { Column } from './DataTable'
export { Dropzone } from './Dropzone'
export { SideNav } from './SideNav'
export { ConfirmDialog } from './ConfirmDialog'
export { NumberInput } from './NumberInput'
export { FieldInput } from './FieldInput'
export { FieldSelect } from './FieldSelect'
export { TimeInput } from './TimeInput'

View File

@@ -0,0 +1,50 @@
import { useState, useEffect, useCallback, useRef } from 'react'
/**
* Polls `fn` every `intervalMs` while `active` is true.
* Returns `countdown` (seconds until next auto-refresh) and a `refresh`
* function for manual triggers — calling it fires `fn` and resets the timer.
*/
export function useAutoRefresh(
fn: () => void,
intervalMs: number,
active: boolean,
): { countdown: number, refresh: () => void } {
const fnRef = useRef(fn)
useEffect(() => {
fnRef.current = fn
})
const secsTotal = Math.round(intervalMs / 1000)
const [countdown, setCountdown] = useState(secsTotal)
useEffect(() => {
if (!active) {
Promise.resolve().then(() => setCountdown(secsTotal))
return
}
Promise.resolve().then(() => setCountdown(secsTotal))
const poll = setInterval(() => {
fnRef.current()
setCountdown(secsTotal)
}, intervalMs)
const tick = setInterval(() => {
setCountdown((s) => Math.max(0, s - 1))
}, 1000)
return () => {
clearInterval(poll)
clearInterval(tick)
}
}, [active, intervalMs, secsTotal])
const refresh = useCallback(() => {
fnRef.current()
setCountdown(secsTotal)
}, [secsTotal])
return { countdown, refresh }
}

View File

@@ -0,0 +1,44 @@
import { useState, useEffect } from 'react'
import { api } from '../api/client'
import type { Status } from '../api/client'
// ConnState distinguishes the initial load from a hard "never reached the
// backend" failure, so the UI can show a setup screen on real connection
// failure without flickering during the first poll.
export type ConnState = 'loading' | 'connected' | 'error'
export interface StatusResult {
status: Status | null
state: ConnState
}
export function useStatus(): StatusResult {
const [status, setStatus] = useState<Status | null>(null)
const [state, setState] = useState<ConnState>('loading')
useEffect(() => {
let everConnected = false
const poll = async () => {
try {
const s = await api.status()
everConnected = true
setStatus(s)
setState('connected')
}
catch {
// Only surface the hard "can't reach backend" screen if we've NEVER
// connected. A transient blip after a successful connect keeps the last
// status — the header's DB/SSH badges already reflect dependency health.
if (!everConnected) {
setStatus(null)
setState('error')
}
}
}
poll()
const id = setInterval(poll, 5000)
return () => clearInterval(id)
}, [])
return { status, state }
}

View File

@@ -0,0 +1,36 @@
import { useMemo, useState } from 'react'
export type SortDir = 'asc' | 'desc'
export function useTableSort<T, K extends string>(
rows: T[],
initialKey: K,
getValue: (row: T, key: K) => string | number | null | undefined,
initialDir: SortDir = 'asc',
) {
const [sortKey, setSortKey] = useState<K>(initialKey)
const [sortDir, setSortDir] = useState<SortDir>(initialDir)
const sorted = useMemo(() => {
const out = [...rows]
out.sort((a, b) => {
const av = getValue(a, sortKey)
const bv = getValue(b, sortKey)
let cmp: number
if (typeof av === 'number' && typeof bv === 'number') cmp = av - bv
else cmp = String(av ?? '').localeCompare(String(bv ?? ''), undefined, { numeric: true })
return sortDir === 'asc' ? cmp : -cmp
})
return out
}, [rows, sortKey, sortDir, getValue])
const toggle = (key: K) => {
if (key === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
else {
setSortKey(key)
setSortDir('asc')
}
}
return { sorted, sortKey, sortDir, toggle }
}

View File

@@ -0,0 +1,11 @@
import 'i18next'
import type enUS from '../locales/en-US/translation.json'
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'translation'
resources: {
translation: typeof enUS
}
}
}

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import enUS from '../locales/en-US/translation.json'
import de from '../locales/de/translation.json'
import fr from '../locales/fr/translation.json'
import es from '../locales/es/translation.json'
import ptBR from '../locales/pt-BR/translation.json'
import ru from '../locales/ru/translation.json'
import pl from '../locales/pl/translation.json'
import tr from '../locales/tr/translation.json'
import zhCN from '../locales/zh-CN/translation.json'
import ja from '../locales/ja/translation.json'
const LOCALES: Record<string, Record<string, unknown>> = {
de,
fr,
es,
'pt-BR': ptBR,
ru,
pl,
tr,
'zh-CN': zhCN,
ja,
}
function flatKeys(obj: Record<string, unknown>, prefix = ''): string[] {
return Object.entries(obj).flatMap(([k, v]) => {
const key = prefix ? `${prefix}.${k}` : k
return typeof v === 'object' && v !== null
? flatKeys(v as Record<string, unknown>, key)
: [key]
})
}
describe('i18n completeness', () => {
const enKeys = flatKeys(enUS as Record<string, unknown>)
it('en-US has no empty values', () => {
enKeys.forEach((key) => {
const parts = key.split('.')
let val: unknown = enUS
for (const p of parts) val = (val as Record<string, unknown>)[p]
expect(typeof val === 'string' && val.length > 0, `en-US key "${key}" is empty`).toBe(true)
})
})
Object.entries(LOCALES).forEach(([locale, translations]) => {
it(`${locale} has all en-US keys`, () => {
const localeKeys = new Set(flatKeys(translations))
enKeys.forEach((key) => {
expect(localeKeys.has(key), `${locale} missing key "${key}"`).toBe(true)
})
})
})
})

View File

@@ -0,0 +1,58 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import enUS from '../locales/en-US/translation.json'
import de from '../locales/de/translation.json'
import fr from '../locales/fr/translation.json'
import es from '../locales/es/translation.json'
import ptBR from '../locales/pt-BR/translation.json'
import ru from '../locales/ru/translation.json'
import pl from '../locales/pl/translation.json'
import tr from '../locales/tr/translation.json'
import zhCN from '../locales/zh-CN/translation.json'
import ja from '../locales/ja/translation.json'
export const LOCALE_KEY = 'dune_admin_locale'
export const DEFAULT_LOCALE = 'en-US'
export const LANGUAGES = [
{ code: 'en-US', label: 'English', flag: '🇨🇦' },
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
{ code: 'fr', label: 'Français', flag: '🇫🇷' },
{ code: 'es', label: 'Español', flag: '🇪🇸' },
{ code: 'pt-BR', label: 'Português (BR)', flag: '🇧🇷' },
{ code: 'ru', label: 'Русский', flag: '🇷🇺' },
{ code: 'pl', label: 'Polski', flag: '🇵🇱' },
{ code: 'tr', label: 'Türkçe', flag: '🇹🇷' },
{ code: 'zh-CN', label: '中文 (简体)', flag: '🇨🇳' },
{ code: 'ja', label: '日本語', flag: '🇯🇵' },
] as const
const saved = localStorage.getItem(LOCALE_KEY) ?? DEFAULT_LOCALE
i18n
.use(initReactI18next)
.init({
resources: {
'en-US': { translation: enUS },
'de': { translation: de },
'fr': { translation: fr },
'es': { translation: es },
'pt-BR': { translation: ptBR },
'ru': { translation: ru },
'pl': { translation: pl },
'tr': { translation: tr },
'zh-CN': { translation: zhCN },
'ja': { translation: ja },
},
lng: saved,
fallbackLng: DEFAULT_LOCALE,
interpolation: { escapeValue: false },
})
export function setLocale(code: string): void {
localStorage.setItem(LOCALE_KEY, code)
void i18n.changeLanguage(code)
}
export default i18n

View File

@@ -0,0 +1,424 @@
@import "tailwindcss";
@import "@heroui/styles";
/* unicode-range restricts Duneway to the Latin/Cyrillic blocks it actually covers.
Symbols, arrows, dingbats (U+2000+) fall through to the system font immediately,
preventing garbled â□□ rendering for ✓ ✗ ✕ — ¾ ³ → ↑ ● etc. */
@font-face {
font-family: "Duneway";
src: url("/fonts/Duneway-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
unicode-range: U+0000-024F, U+0400-04FF, U+1E00-1EFF;
}
@font-face {
font-family: "Duneway";
src: url("/fonts/Duneway-Medium.ttf") format("truetype");
font-weight: 400 500;
font-style: normal;
font-display: swap;
unicode-range: U+0000-024F, U+0400-04FF, U+1E00-1EFF;
}
@font-face {
font-family: "Duneway";
src: url("/fonts/Duneway-ExtraLight.ttf") format("truetype");
font-weight: 200;
font-style: normal;
font-display: swap;
unicode-range: U+0000-024F, U+0400-04FF, U+1E00-1EFF;
}
@font-face {
font-family: "Duneway";
src: url("/fonts/Duneway-Bold-Italic_MonoNumbers_Kommafix_Cyrfix.ttf") format("truetype");
font-weight: 700;
font-style: italic;
font-display: swap;
unicode-range: U+0000-024F, U+0400-04FF, U+1E00-1EFF;
}
@font-face {
font-family: "Duneway";
src: url("/fonts/Duneway-Italic_MonoNumbers_Kommafix_Cyrfix.ttf") format("truetype");
font-weight: 400;
font-style: italic;
font-display: swap;
unicode-range: U+0000-024F, U+0400-04FF, U+1E00-1EFF;
}
@font-face {
font-family: "ImperialFont";
src: url("/fonts/ImperialFont.ttf") format("truetype");
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* ── Dune desert dark theme ──────────────────────────────────────────────────
Single source of truth for the dune palette. We override HeroUI v3 semantic
tokens directly so utility classes (`bg-surface`, `text-foreground`,
`border-border`, `bg-accent`, etc.) and HeroUI components both pick up the
amber/sand colors automatically — no need for inline style={{}} overrides.
`.dark` is applied on <html> in index.html.
─────────────────────────────────────────────────────────────────────────── */
:root {
--font-sans: "Duneway", ui-sans-serif, system-ui, sans-serif;
}
.dark {
color-scheme: dark;
/* ── Dune monochromatic amber ladder (light → dark) ──────────────────────
#f0a830 — accent hover / link
#c9820a — primary accent
#9e6711 — secondary accent
#754d13 — soft accent borders / scrollbar
#4e3411 — borders / hover surface / elevated panels
#2a1d0c — primary surface (cards, tables, modal body)
#1a1610 — subtle stripe / field background
#0d0b07 — page background (near-black warm) */
/* Base — surfaces use the DARKER half of the palette */
--background: #0d0b07; /* page */
--foreground: #e8dcc8;
--muted: #8a7a60;
/* Surfaces (darker) */
--surface: #1a1610; /* cards, tables, modal body */
--surface-foreground: #e8dcc8;
--surface-secondary: #2a1d0c; /* slightly elevated panels */
--surface-tertiary: #4e3411; /* top elevation (table headers) */
--overlay: #1a1610; /* modal body */
--overlay-foreground: #e8dcc8;
--default: #1a1610;
--default-foreground: #e8dcc8;
/* Accent — dune amber */
--accent: #c9820a;
--accent-foreground: #0d0b07;
--focus: #f0a830;
--link: #f0a830;
/* Form fields — recessed bg + BRIGHTER border for emphasis */
--field-background: #0d0b07;
--field-foreground: #e8dcc8;
--field-placeholder: #8a7a60;
--field-border: #754d13; /* brighter — buttons & inputs pop */
/* Border widths + radii — global override: thin border, near-square corners */
--border-width: 0.1em;
--field-border-width: 0.1em;
--radius: 0.1em;
--field-radius: 0.1em;
/* Status — hue-shifted from accent to stay in family */
--success: #b0c90a; /* amber-green */
--success-foreground: #0d0b07;
--warning: #f0a830; /* lighter amber */
--warning-foreground: #0d0b07;
--danger: #c9230a; /* amber-red */
--danger-foreground: #ffffff;
/* Item rarity tiers — used by Market grid/table to color names & borders */
--rarity-common: var(--foreground);
--rarity-uncommon: var(--success);
--rarity-rare: #4a90e2; /* blue */
--rarity-epic: #a855f7; /* purple */
--rarity-legendary: #f0a830; /* amber */
--rarity-unique: #f97316; /* orange */
--rarity-memento: #f43f5e; /* rose */
/* Borders / separators — mid-tone */
--border: #4e3411;
--separator: #2a1d0c; /* subtle in-table dividers */
/* Scrollbar */
--scrollbar: #754d13;
/* Dune-specific extras (not part of HeroUI's standard set) */
--surface-alt: #14110b; /* striped table rows — between bg and surface */
--surface-hover: #2a1d0c; /* hover state for rows / nav */
--accent-soft-bg: #2a1d0c; /* faint amber pill background */
--accent-soft-border: #754d13;
/* ── Legacy aliases ────────────────────────────────────────────────────────
Existing markup uses `var(--color-*)` everywhere. Map those to the new
semantic tokens so nothing breaks while we migrate tab-by-tab. */
--color-background: var(--background);
--color-surface: var(--surface);
--color-surface-alt: var(--surface-alt);
--color-primary: var(--accent);
--color-primary-hover: var(--link);
--color-text: var(--foreground);
--color-text-dim: var(--muted);
--color-border: var(--border);
--color-danger: var(--danger);
--color-success: var(--success);
}
/* ── Tailwind v4 utility surface ─────────────────────────────────────────────
Expose the dune-specific extras as Tailwind colors so we can write
`bg-surface-alt`, `border-border-strong`, etc. The standard HeroUI tokens
(`bg-surface`, `bg-accent`, etc.) are wired by @heroui/styles already. */
@theme {
--color-surface-alt: var(--surface-alt);
--color-surface-hover: var(--surface-hover);
--color-accent-soft: var(--accent-soft-bg);
--color-rarity-common: var(--rarity-common);
--color-rarity-uncommon: var(--rarity-uncommon);
--color-rarity-rare: var(--rarity-rare);
--color-rarity-epic: var(--rarity-epic);
--color-rarity-legendary: var(--rarity-legendary);
--color-rarity-unique: var(--rarity-unique);
--color-rarity-memento: var(--rarity-memento);
}
html,
body,
#root {
background-color: var(--background);
color: var(--foreground);
min-height: 100vh;
font-family: "Duneway", system-ui, sans-serif;
margin: 0;
padding: 0;
/* Prevent the page itself from scrolling — the app is bounded to h-screen.
The !important beats React Aria's inline overflow:hidden on modal scroll-lock,
keeping the scrollbar gutter stable so modals don't cause layout shift. */
overflow: hidden !important;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--surface);
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent);
}
/* ── HeroUI Checkbox + Switch — the root <label> has no position:relative,
so the VisuallyHidden <input> (position:absolute, margin:-1px) can escape
its container and cause scroll jumps when focused. We also clamp the
VisuallyHidden wrapper span itself to ensure it can never affect layout. */
.checkbox,
.switch {
position: relative;
align-items: center;
}
/* The VisuallyHidden span wrapping the hidden <input> inside Checkbox/Switch.
React Aria renders it as position:absolute with margin:-1px — without a
positioned ancestor the margin can still influence scroll-extent calculations
in some browsers. Force all layout-affecting properties to be inert. */
.checkbox > span[style],
.switch > span[style] {
position: absolute !important;
width: 1px !important;
height: 1px !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
clip: rect(0 0 0 0) !important;
clip-path: inset(50%) !important;
white-space: nowrap !important;
border: 0 !important;
pointer-events: none !important;
}
/* ── HeroUI Table — compact density. HeroUI doesn't ship a size prop, so
we override cell/header padding + font globally for a consistent look. */
[role="columnheader"],
[role="rowheader"],
[role="gridcell"] {
padding: 0.375rem 0.75rem !important; /* py-1.5 px-3 */
font-size: 0.75rem; /* text-xs */
line-height: 1.25rem;
}
/* Column headers — sit on the body bg with a hairline divider below.
Subtle elevation; the amber text + uppercase is what makes them read. */
[role="columnheader"] {
background: linear-gradient(180deg, var(--surface-secondary) 0%, var(--surface) 100%);
color: var(--accent);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border);
border-top: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 1;
}
/* Base cells = darkest (page bg); even rows get the lighter zebra stripe. */
[role="rowheader"],
[role="gridcell"] {
background: var(--background) !important;
}
[role="row"]:nth-child(even) > [role="rowheader"],
[role="row"]:nth-child(even) > [role="gridcell"] {
background: var(--surface) !important;
}
/* Accent left stripe on row hover — inset shadow avoids layout shift. */
[role="row"]:hover > [role="rowheader"],
[role="row"]:hover > [role="gridcell"]:first-of-type {
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
}
/* ── Tabs.Panel — HeroUI's default panel bg is too amber for our theme.
Force the page background so the content area reads dark, not brown. */
[role="tabpanel"],
.tabs__panel {
background: var(--background) !important;
}
/* Depth/hillshade overlay blends into the tile layer for perceived terrain depth. */
.leaflet-depth-overlay {
mix-blend-mode: multiply;
opacity: 0.55;
pointer-events: none;
}
/* Leaflet's control elements (z-index: 1000) escape their container's stacking
context. Override dialogs/modals to always appear above the map. */
.alert-dialog__backdrop,
.modal__backdrop {
z-index: 9999;
}
/* ── Top tabs — uppercase white labels, amber pill when selected.
The selected-state pill is rendered by `Tabs.Indicator`, NOT the tab's
own background, so we color that element directly to match the
"DUNE ADMIN" logo (`--accent`). */
[role="tab"] {
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--foreground);
font-weight: 600;
background: transparent !important;
width: fit-content;
justify-content: space-between;
}
.tabs__indicator {
background-color: var(--accent) !important;
}
.tabs__list {
justify-content: space-between;
}
[role="tab"][data-selected="true"],
[role="tab"][aria-selected="true"],
[role="tab"][data-state="active"] {
color: var(--accent-foreground) !important;
}
/* ── Listbox option hover / focus / selected — used by every Select,
Autocomplete, and standalone ListBox in the app. HeroUI doesn't give
these a visible hover state by default in our dark theme. */
[role="option"]:hover,
[role="option"][data-hovered="true"],
[role="option"][data-focused="true"],
[role="option"][data-focus-visible="true"] {
background-color: var(--surface-hover) !important;
color: var(--foreground);
}
[role="option"][data-selected="true"],
[role="option"][aria-selected="true"] {
background-color: var(--accent-soft-bg) !important;
color: var(--accent);
}
/* ── HeroUI Tabs + Table containers leak brown bg from `--default` / their
own internal tokens. Null them out so the page bg shows through. */
.tabs,
.tabs__list-container,
.tabs__list,
[role="tablist"],
[role="tabpanel"],
.tabs__panel,
.table,
.table__scroll-container,
.table__content,
[role="grid"],
[role="row"],
[role="rowgroup"] {
background: transparent !important;
}
/* Cells: transparent by default; even-row cells get the subtle stripe. */
[role="rowheader"],
[role="gridcell"] {
background: transparent;
}
/* ── HeroUI modal dialog — gradient body matches the panel language. */
[role="dialog"] {
background: linear-gradient(170deg, var(--surface-secondary) 0%, var(--background) 100%) !important;
}
.dialog-surface-alt[role="dialog"] {
background: var(--surface-alt) !important;
}
/* ── Pre / monospace output blocks ─────────────────────────────────────── */
pre,
.font-mono[class*="border"] {
border-radius: var(--radius) !important;
}
/* ── Surface lift — deep shadow stack ───────────────────────────────────
Adds perceived depth to card/panel surfaces: top-edge rim using the
theme accent-soft-border, a gradient that darkens toward the bottom,
and a stacked drop-shadow. Use alongside bg-surface* + border-border. */
.dune-lift {
background: linear-gradient(170deg, var(--surface-secondary) 0%, var(--background) 100%) !important;
border-top-color: var(--accent-soft-border) !important;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.35),
0 4px 10px rgba(0, 0, 0, 0.25);
}
/* Clerk modal backdrop — Clerk injects this via inline style so the
appearance.elements className alone doesn't override it. */
.cl-modalBackdrop,
[class*="cl-modalBackdrop"] {
background-color: rgba(13, 11, 7, 0.82) !important;
backdrop-filter: blur(6px) !important;
-webkit-backdrop-filter: blur(6px) !important;
}
/* Clerk card — amber-tinted border so it reads as a deliberate surface
against the dark scrim rather than a floating box with no edges. */
.cl-cardBox,
.cl-card,
[class*="cl-cardBox"],
[class*="cl-card "] {
border: 1px solid rgba(201, 130, 10, 0.2) !important;
box-shadow:
0 18px 48px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.02) inset !important;
}
/* ── Tab content fade-in ──────────────────────────────────────────────────
Triggered when a keep-alive TabPane becomes active (hidden → flex).
Pure opacity fade — no transform to avoid layout thrash on content reloads. */
@keyframes dune-tab-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.dune-tab-active {
animation: dune-tab-in 180ms ease-out both;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import './index.css'
import './i18n'
import { applyTheme, loadTheme } from './theme'
import { App } from './App.tsx'
import { ClerkProvider } from '@clerk/react'
import { dark } from '@clerk/themes'
// Apply the saved appearance theme (#144) before first paint to avoid a flash.
applyTheme(loadTheme())
const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY
// Match Clerk modals to the dune-admin dark amber theme.
// Element class overrides are needed for the backdrop because Clerk injects
// it via inline style — the appearance.elements className alone doesn't win.
const clerkAppearance = {
baseTheme: dark,
variables: {
colorPrimary: '#c9820a',
colorDanger: '#c9230a',
borderRadius: '2px',
fontFamily: 'system-ui, -apple-system, sans-serif',
},
elements: {
formButtonPrimary:
'bg-[#c9820a] hover:bg-[#d4900f] text-black font-bold shadow-none normal-case tracking-normal',
footerActionLink: 'text-[#c9820a] hover:text-[#d4900f]',
},
} as const
createRoot(document.getElementById('root')!).render(
<StrictMode>
<HashRouter>
{publishableKey
? (
<ClerkProvider publishableKey={publishableKey} afterSignOutUrl="/" appearance={clerkAppearance}>
<App />
</ClerkProvider>
)
: (
<App />
)}
</HashRouter>
</StrictMode>,
)

View File

@@ -0,0 +1,141 @@
import { useState, useEffect, useCallback } from 'react'
import type React from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Card, Spinner, toast } from '@heroui/react'
import { api, ApiError } from '../api/client'
import type { BaseRow } from '../api/client'
import { DataTable, Icon, PageHeader, type Column } from '../dune-ui'
type Key = 'id' | 'name' | 'pieces' | 'placeables' | 'actions'
interface BasesTabProps {
isSignedIn?: boolean
}
export const BasesTab: React.FC<BasesTabProps> = ({ isSignedIn = true }) => {
const { t } = useTranslation()
const [bases, setBases] = useState<BaseRow[]>([])
const [loading, setLoading] = useState(false)
const [unsupported, setUnsupported] = useState(false)
const COLUMNS: Column<Key>[] = [
{ key: 'id', label: t('bases.columns.id'), width: 80 },
{ key: 'name', label: t('bases.columns.name'), minWidth: 220 },
{ key: 'pieces', label: t('bases.columns.pieces'), width: 100 },
{ key: 'placeables', label: t('bases.columns.placeables'), width: 110 },
{ key: 'actions', label: '', width: 120, sortable: false },
]
const load = useCallback(() => {
Promise.resolve()
.then(() => {
setLoading(true)
setUnsupported(false)
})
.then(() => api.bases.list())
.then(setBases)
.catch((e: unknown) => {
if (e instanceof ApiError && e.status === 404) setUnsupported(true)
else toast.danger(t('bases.failedToLoad', { message: e instanceof Error ? e.message : String(e) }))
})
.finally(() => setLoading(false))
}, [t])
useEffect(() => {
load()
}, [load])
return (
<div className="flex flex-col h-full gap-3 min-h-0">
{!isSignedIn && (
<div className="shrink-0 rounded-[var(--radius)] px-4 py-2 text-xs font-medium bg-danger/10 border border-danger/40 text-danger flex items-center gap-2">
<Icon name="triangle-alert" />
<span>
A
{' '}
<strong>{t('bases.layoutAccountStrong')}</strong>
{' '}
account is required to export bases. Sign in using the button in the top
right.
</span>
</div>
)}
<PageHeader
title={t('bases.title', { count: bases.length })}
subtitle={t('bases.subtitle')}
>
<Button size="sm" variant="ghost" onPress={load} isDisabled={loading}>
{loading
? (
<Spinner size="sm" color="current" />
)
: (
<>
<Icon name="refresh-cw" />
{' '}
{t('common.refresh')}
</>
)}
</Button>
</PageHeader>
{unsupported
? (
<Card className="self-center max-w-sm">
<Card.Header>
<Card.Title className="text-accent text-sm">{t('bases.featureNotAvailable')}</Card.Title>
</Card.Header>
<Card.Content>
<p className="text-xs text-muted text-center">
{t('bases.featureNotAvailableDesc')}
</p>
</Card.Content>
</Card>
)
: (
<DataTable<BaseRow, Key>
aria-label={t('bases.ariaLabel')}
className="min-h-0 max-h-full"
columns={COLUMNS}
rows={bases}
loading={loading}
rowId={(b) => String(b.id)}
initialSort={{ column: 'id', direction: 'ascending' }}
sortValue={(b, k) => (k === 'actions' ? '' : (b as unknown as Record<string, string | number>)[k])}
emptyState={<div className="py-8 text-center text-muted">{t('bases.noBasesFound')}</div>}
renderCell={(b, key) => {
switch (key) {
case 'id':
return <span className="font-mono text-muted">{b.id}</span>
case 'name':
return b.name || <span className="text-muted"></span>
case 'pieces':
return <span className="text-muted">{b.pieces}</span>
case 'placeables':
return <span className="text-muted">{b.placeables}</span>
case 'actions':
return isSignedIn
? (
<a href={api.bases.exportUrl(b.id)} download={b.name ? `${b.name}.json` : `base-${b.id}.json`}>
<Button size="sm" variant="outline" className="w-full">
<Icon name="download" />
{' '}
{t('bases.export')}
</Button>
</a>
)
: (
<Button size="sm" variant="outline" className="w-full" isDisabled>
<Icon name="download" />
{' '}
{t('bases.export')}
</Button>
)
}
}}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,72 @@
import type React from 'react'
import { useTranslation } from 'react-i18next'
import { DataTable, Icon } from '../../dune-ui'
import { phaseColor } from './helpers'
import { formatUptime } from './uptime'
import { getServerColumns, type ServerRow, type ServerSortKey } from './types'
type ServersTableProps = {
servers: ServerRow[]
isInitializing: boolean
emptyMessage?: string
}
export const ServersTable: React.FC<ServersTableProps> = ({ servers, isInitializing, emptyMessage }) => {
const { t } = useTranslation()
return (
<DataTable<ServerRow, ServerSortKey>
aria-label={t('nav.battlegroup')}
className="min-h-0 max-h-full"
columns={getServerColumns(t)}
rows={servers}
rowId={(s) => `${s.map}-${s.dimension}-${s.partition}`}
initialSort={{ column: 'map', direction: 'ascending' }}
sortValue={(r, k) => {
if (k === 'ready') return r.ready ? 1 : 0
if (k === 'age') return r.ageSeconds ?? 0
return r[k] as string | number
}}
emptyState={emptyMessage && <div className="py-8 text-center text-muted">{emptyMessage}</div>}
renderCell={(s, key) => {
switch (key) {
case 'map':
return <span className="font-mono">{s.map}</span>
case 'phase':
return (
<span className="font-semibold" style={{ color: phaseColor(s.phase) }}>
{s.phase || '—'}
{isInitializing && s.phase === 'Running' && (
<span className="ml-1 font-normal text-warning">{t('battlegroup.initializing')}</span>
)}
</span>
)
case 'players':
return (
<span className="font-semibold" style={{ color: s.players > 0 ? 'var(--success)' : 'var(--muted)' }}>
{s.players}
{s.playerHardCap > 0 && (
<span className="font-normal text-muted">{`/${s.playerHardCap}`}</span>
)}
</span>
)
case 'queue':
return (
<span style={{ color: s.queue > 0 ? 'var(--warning)' : 'var(--muted)' }}>
{s.queue}
</span>
)
case 'ready':
return (
<Icon
name={s.ready ? 'check' : 'x'}
className={`size-4 ${s.ready ? 'text-success' : 'text-danger'}`}
/>
)
case 'dimension': return <span className="text-muted">{s.dimension}</span>
case 'partition': return <span className="text-muted">{s.partition}</span>
case 'age': return <span className="font-mono text-muted">{formatUptime(s.ageSeconds)}</span>
}
}}
/>
)
}

View File

@@ -0,0 +1,310 @@
import type React from 'react'
import type { ReactNode } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Chip, Spinner, toast } from '@heroui/react'
import { Icon, SectionLabel, FieldInput } from '../../../dune-ui'
import { copyText } from '../../../utils/clipboard'
import { api } from '../../../api/client'
import type { Status, WebInterface } from '../../../api/client'
import type { BGInfo, ServerRow } from '../types'
import { phaseColor, phaseChipColor, bgUptimeSeconds, allServersReady } from '../helpers'
import { formatUptime, portRange } from '../uptime'
// Cards that read both the battlegroup status and the connection status share
// this prop shape.
type HealthProps = { bg?: BGInfo, servers: ServerRow[], status: Status | null }
// ── Card wrapper ──────────────────────────────────────────────────────────────
// HealthCard is the titled panel shell every Server Health card shares: an
// uppercase section label (optionally icon-led) with an optional right-aligned
// accessory, over the card body.
export const HealthCard: React.FC<{
title: string
icon?: string
accessory?: ReactNode
className?: string
children: ReactNode
}> = ({ title, icon, accessory, className = '', children }) => (
<div className={`rounded-[var(--radius)] p-4 flex flex-col gap-3 bg-surface-secondary border border-border dune-lift ${className}`}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{icon && <Icon name={icon} className="size-4 text-accent" />}
<SectionLabel>{title}</SectionLabel>
</div>
{accessory}
</div>
{children}
</div>
)
// ── Top status-chip bar ─────────────────────────────────────────────────────
export const HealthChips: React.FC<HealthProps> = ({ bg, servers, status }) => {
const { t } = useTranslation()
const ports = portRange(servers.map((s) => s.port ?? 0))
// listen_addr is like ":9090" or "0.0.0.0:9090" — show just the port.
const webPort = (status?.listen_addr ?? '').split(':').pop() || '—'
return (
<div className="flex flex-wrap items-center gap-2 shrink-0">
<Chip size="sm" variant="soft" color="default">
<Icon name="network" className="size-3" />
{' '}
{t('serverHealth.gamePorts')}
{': '}
{ports}
</Chip>
<Chip size="sm" variant="soft" color="default">
<Icon name="globe" className="size-3" />
{' '}
{t('serverHealth.webPort')}
{': '}
{webPort}
</Chip>
<div className="flex-1" />
<Chip size="sm" variant="soft" color={phaseChipColor(status?.control && status.control !== 'none' ? 'running' : 'stopped')}>
{t('serverHealth.vm')}
{' · '}
{status?.control && status.control !== 'none' ? t('serverHealth.up') : t('serverHealth.down')}
</Chip>
<Chip size="sm" variant="soft" color={phaseChipColor(bg?.phase ?? '')}>
{t('serverHealth.bg')}
{' · '}
{bg?.phase || '—'}
</Chip>
</div>
)
}
// ── Battlegroup + VM headline card ───────────────────────────────────────────
export const BgVmCard: React.FC<{ bg?: BGInfo, servers: ServerRow[] }> = ({ bg, servers }) => {
const { t } = useTranslation()
const uptime = bgUptimeSeconds(servers)
return (
<HealthCard title={t('serverHealth.bgVm')} icon="activity">
<div className="text-3xl font-semibold" style={{ color: phaseColor(bg?.phase ?? '') }}>
{bg?.phase || '—'}
</div>
<div className="text-sm text-muted">
{uptime > 0 ? t('serverHealth.upFor', { uptime: formatUptime(uptime) }) : t('serverHealth.noUptime')}
</div>
</HealthCard>
)
}
// ── Component-health rows ─────────────────────────────────────────────────────
const HealthRow: React.FC<{ label: string, value: string, color?: string }> = ({ label, value, color }) => (
<div className="flex items-center justify-between py-1 border-b border-border/40 last:border-0">
<span className="text-muted text-sm">{label}</span>
<span className="font-semibold text-sm" style={color ? { color } : undefined}>{value}</span>
</div>
)
export const ComponentHealthCard: React.FC<HealthProps> = ({ bg, servers, status }) => {
const { t } = useTranslation()
const uptime = bgUptimeSeconds(servers)
const directorSet = !!status?.director_url
return (
<HealthCard title={t('serverHealth.components')} icon="server">
<div className="flex flex-col">
<HealthRow label={t('serverHealth.bgState')} value={bg?.phase || '—'} color={phaseColor(bg?.phase ?? '')} />
<HealthRow label={t('serverHealth.database')} value={bg?.database || '—'} color={phaseColor(bg?.database ?? '')} />
<HealthRow
label={t('serverHealth.director')}
value={directorSet ? t('serverHealth.configured') : t('serverHealth.notConfigured')}
color={directorSet ? 'var(--success)' : 'var(--muted)'}
/>
<HealthRow label={t('serverHealth.uptime')} value={formatUptime(uptime)} />
</div>
</HealthCard>
)
}
// ── Game ready state ──────────────────────────────────────────────────────────
export const GameReadyCard: React.FC<{ bg?: BGInfo, servers: ServerRow[] }> = ({ bg, servers }) => {
const { t } = useTranslation()
const ready = allServersReady(bg?.phase, servers)
return (
<HealthCard title={t('serverHealth.readyState')} icon="heart-pulse">
<div className="flex items-center gap-2">
<Icon name={ready ? 'circle-check' : 'circle-x'} className={`size-6 ${ready ? 'text-success' : 'text-muted'}`} />
<span className="text-2xl font-semibold" style={{ color: ready ? 'var(--success)' : 'var(--muted)' }}>
{ready ? t('serverHealth.ready') : t('serverHealth.notReady')}
</span>
</div>
</HealthCard>
)
}
// ── Web interfaces (#155: operator-configurable list) ────────────────────────
const InterfaceRow: React.FC<{ item: WebInterface }> = ({ item }) => {
const { t } = useTranslation()
const copy = () => {
copyText(item.url).then((ok) =>
(ok ? toast.success(t('serverHealth.copied')) : toast.danger(t('serverHealth.copyFailed'))))
}
return (
<div className="flex items-center gap-2">
<Icon name="external-link" className="size-4 text-accent" />
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-semibold">{item.label}</span>
<span className="text-xs text-muted font-mono truncate">{item.url}</span>
</div>
<Button size="sm" variant="ghost" isIconOnly aria-label={t('serverHealth.copy')} onPress={copy}>
<Icon name="copy" />
</Button>
<Button size="sm" variant="outline" onPress={() => window.open(item.url, '_blank', 'noopener')}>
{t('serverHealth.open')}
</Button>
</div>
)
}
// DirectorRow is the automatic, read-only entry shown when director_url is set:
// the Director usually binds to loopback on the host, so "Open" goes through the
// same-origin /director/ reverse proxy. The configured target is shown for context.
const DirectorRow: React.FC<{ directorURL: string }> = ({ directorURL }) => {
const { t } = useTranslation()
const copy = () => {
copyText(`${window.location.origin}/director/`).then((ok) =>
(ok ? toast.success(t('serverHealth.copied')) : toast.danger(t('serverHealth.copyFailed'))))
}
return (
<div className="flex items-center gap-2">
<Icon name="external-link" className="size-4 text-accent" />
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-semibold">
{t('serverHealth.director')}
{' '}
<span className="text-xs font-normal text-muted">{t('serverHealth.directorProxied')}</span>
</span>
<span className="text-xs text-muted font-mono truncate">{directorURL}</span>
</div>
<Button size="sm" variant="ghost" isIconOnly aria-label={t('serverHealth.copy')} onPress={copy}>
<Icon name="copy" />
</Button>
<Button size="sm" variant="outline" onPress={() => window.open('/director/', '_blank', 'noopener')}>
{t('serverHealth.open')}
</Button>
</div>
)
}
export const WebInterfacesCard: React.FC<{ status: Status | null }> = ({ status }) => {
const { t } = useTranslation()
const [items, setItems] = useState<WebInterface[]>([])
const [draft, setDraft] = useState<WebInterface[]>([])
const [editing, setEditing] = useState(false)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const director = status?.director_url
const load = useCallback(() => {
Promise.resolve()
.then(() => setLoading(true))
.then(() => api.webInterfaces.get())
.then((res) => setItems(res.interfaces ?? []))
.catch((e: unknown) =>
toast.danger(t('serverHealth.ifaceLoadFailed', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setLoading(false))
}, [t])
useEffect(() => {
load()
}, [load])
const startEdit = () => {
setDraft(items.length ? items.map((i) => ({ ...i })) : [{ label: '', url: '' }])
setEditing(true)
}
const setField = (i: number, key: 'label' | 'url', v: string) =>
setDraft((d) => d.map((row, idx) => (idx === i ? { ...row, [key]: v } : row)))
const save = () => {
const clean = draft.filter((r) => r.label.trim() && r.url.trim())
setSaving(true)
api.webInterfaces.update(clean)
.then((res) => {
toast.success(res.ok)
setEditing(false)
load()
})
.catch((e: unknown) =>
toast.danger(t('serverHealth.ifaceSaveFailed', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setSaving(false))
}
const editBtn = (
<Button size="sm" variant="ghost" isIconOnly aria-label={t('serverHealth.editInterfaces')} onPress={startEdit}>
<Icon name="pencil" />
</Button>
)
return (
<HealthCard title={t('serverHealth.webInterfaces')} icon="layout" accessory={!editing && !loading ? editBtn : undefined}>
{loading && <div className="py-2 flex justify-center"><Spinner size="sm" color="current" /></div>}
{!loading && director && <DirectorRow directorURL={director} />}
{!loading && editing && (
<div className="flex flex-col gap-2">
{draft.map((row, i) => (
<div key={i} className="flex items-center gap-2">
<FieldInput
value={row.label}
placeholder={t('serverHealth.ifaceLabel')}
onChange={(v) => setField(i, 'label', v)}
ariaLabel={t('serverHealth.ifaceLabel')}
className="w-32"
/>
<FieldInput
value={row.url}
placeholder={t('serverHealth.ifaceUrl')}
onChange={(v) => setField(i, 'url', v)}
ariaLabel={t('serverHealth.ifaceUrl')}
className="flex-1 font-mono"
/>
<Button
size="sm"
variant="ghost"
isIconOnly
aria-label={t('serverHealth.removeInterface')}
onPress={() => setDraft((d) => d.filter((_, idx) => idx !== i))}
>
<Icon name="trash-2" />
</Button>
</div>
))}
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onPress={() => setDraft((d) => [...d, { label: '', url: '' }])}>
<Icon name="plus" />
{' '}
{t('serverHealth.addInterface')}
</Button>
<div className="flex-1" />
<Button size="sm" variant="ghost" onPress={() => setEditing(false)}>{t('common.cancel')}</Button>
<Button size="sm" onPress={save} isDisabled={saving}>
{saving ? <Spinner size="sm" color="current" /> : t('serverHealth.saveInterfaces')}
</Button>
</div>
</div>
)}
{!loading && !editing && !director && items.length === 0 && (
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-muted">{t('serverHealth.noInterfaces')}</span>
<Button size="sm" variant="outline" onPress={startEdit}>
<Icon name="plus" />
{' '}
{t('serverHealth.addInterface')}
</Button>
</div>
)}
{!loading && !editing && items.length > 0 && (
<div className="flex flex-col gap-2">
{items.map((it) => <InterfaceRow key={`${it.label}|${it.url}`} item={it} />)}
</div>
)}
</HealthCard>
)
}

View File

@@ -0,0 +1,52 @@
/**
* Map a server / battlegroup phase string to a CSS color from our semantic
* tokens. Used for the inline-text phase label in the InfoCard and the
* Phase column of the servers table.
*/
export function phaseColor(phase: string): string {
switch (phase?.toLowerCase()) {
case 'running': return 'var(--success)'
case 'reconciling':
case 'starting':
case 'initializing': return 'var(--warning)'
case 'stopping':
case 'preshutdown':
case 'terminating': return 'var(--danger)'
case 'stopped':
case 'terminated': return 'var(--muted)'
default: return 'var(--muted)'
}
}
export type ChipColor = 'default' | 'success' | 'warning' | 'danger'
/**
* Map a phase string to a HeroUI Chip colour (the chip variant of [[phaseColor]]).
* Used for the Server Health status chips and component-health rows.
*/
export function phaseChipColor(phase: string): ChipColor {
switch (phase?.toLowerCase()) {
case 'running':
case 'ready':
case 'connected':
case 'healthy': return 'success'
case 'reconciling':
case 'starting':
case 'initializing': return 'warning'
case 'stopping':
case 'preshutdown':
case 'terminating':
case 'disconnected': return 'danger'
default: return 'default'
}
}
/** BG uptime = the oldest running game process's age (0 when unknown). */
export function bgUptimeSeconds(servers: { ageSeconds?: number }[]): number {
return servers.reduce((max, s) => Math.max(max, s.ageSeconds ?? 0), 0)
}
/** Game is "ready" only when every running server reports ready. */
export function allServersReady(phase: string | undefined, servers: { ready: boolean }[]): boolean {
return servers.length > 0 && phase === 'Running' && servers.every((s) => s.ready)
}

View File

@@ -0,0 +1,381 @@
import type React from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useAutoRefresh } from '../../hooks/useAutoRefresh'
import { Button, Input, Select, ListBox, Spinner, toast, TextField } from '@heroui/react'
import { api } from '../../api/client'
import type { BackupFile } from '../../api/client'
import { NumberInput, PageHeader, SectionDivider, Icon } from '../../dune-ui'
import { ScheduledRestartsCard } from '../../components/ScheduledRestartsCard'
import { useStatus } from '../../hooks/useStatus'
import { ACTIONS, INIT_WARN_MS, type ActionDef, type DetailedStatus } from './types'
import { ServersTable } from './ServersTable'
import {
HealthCard, HealthChips, BgVmCard, ComponentHealthCard, GameReadyCard, WebInterfacesCard,
} from './components/ServerHealth'
import { ConfirmDialog } from './modals/ConfirmDialog'
import { CommandOutputModal } from './modals/CommandOutputModal'
import { RestoreModal } from './modals/RestoreModal'
const POLL_MS = 30_000
interface BattlegroupTabProps {
isActive?: boolean
}
export const BattlegroupTab: React.FC<BattlegroupTabProps> = ({ isActive = false }) => {
const { t } = useTranslation()
const { status: connStatus } = useStatus()
const [status, setStatus] = useState<DetailedStatus | null>(null)
const [statusLoading, setStatusLoading] = useState(false)
// Command lifecycle
const [runningCmd, setRunningCmd] = useState<string | null>(null)
const [cmdOutput, setCmdOutput] = useState<string | null>(null)
const [cmdDone, setCmdDone] = useState(false)
const [confirmCmd, setConfirmCmd] = useState<ActionDef | null>(null)
const [startedAt, setStartedAt] = useState<number | null>(null)
const [lastBackupFile, setLastBackupFile] = useState<string | null>(null)
// Broadcasts
const [broadcastTitle, setBroadcastTitle] = useState('')
const [broadcastBody, setBroadcastBody] = useState('')
const [broadcastDuration, setBroadcastDuration] = useState(30)
const [broadcastBusy, setBroadcastBusy] = useState(false)
const [shutdownType, setShutdownType] = useState('Restart')
const [shutdownDelay, setShutdownDelay] = useState(10)
const [shutdownBusy, setShutdownBusy] = useState(false)
// Restore modal
const [showRestore, setShowRestore] = useState(false)
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
const [backupFilesLoading, setBackupFilesLoading] = useState(false)
const fetchStatus = useCallback(() => {
Promise.resolve()
.then(() => setStatusLoading(true))
.then(() => api.battlegroup.status() as Promise<unknown>)
.then((res) => setStatus(res as DetailedStatus))
.catch((e: unknown) => toast.danger(t('battlegroup.statusFailed', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setStatusLoading(false))
}, [t])
useEffect(() => {
fetchStatus()
}, [fetchStatus])
const { countdown, refresh: refreshStatus } = useAutoRefresh(fetchStatus, POLL_MS, isActive)
// isInitializing tracks whether we're inside the post-start warning window.
// We use a boolean state rather than computing from Date.now() in render (impure).
const [isInitializing, setIsInitializing] = useState(false)
useEffect(() => {
if (startedAt === null) {
const t = setTimeout(() => setIsInitializing(false), 0)
return () => clearTimeout(t)
}
const remaining = INIT_WARN_MS - (Date.now() - startedAt)
if (remaining <= 0) {
const t = setTimeout(() => setStartedAt(null), 0)
return () => clearTimeout(t)
}
const tStart = setTimeout(() => setIsInitializing(true), 0)
const tEnd = setTimeout(() => {
setStartedAt(null)
setIsInitializing(false)
}, remaining)
return () => {
clearTimeout(tStart)
clearTimeout(tEnd)
}
}, [startedAt])
const runCmd = async (action: ActionDef) => {
setConfirmCmd(null)
setRunningCmd(action.cmd)
setCmdOutput(null)
setCmdDone(false)
try {
const res = await api.battlegroup.exec(action.cmd)
setCmdOutput(res.output || t('battlegroup.noOutput'))
setCmdDone(true)
if (action.cmd === 'start' || action.cmd === 'restart') setStartedAt(Date.now())
if (action.cmd === 'backup') {
const match = (res.output || '').match(/database-dumps\/[^/]+\/([^\s]+\.backup)/)
if (match) setLastBackupFile(match[1])
}
toast.success(t('battlegroup.cmdCompleted', { label: t(`battlegroup.actions.${action.cmd}` as never) }))
fetchStatus()
}
catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
setCmdOutput(`Error: ${msg}`)
setCmdDone(true)
toast.danger(t('battlegroup.cmdFailed', { label: t(`battlegroup.actions.${action.cmd}` as never), message: msg }))
}
}
const openRestore = () => {
setBackupFilesLoading(true)
setBackupFiles([])
setShowRestore(true)
api.battlegroup.backupFiles()
.then(setBackupFiles)
.catch(() => toast.danger(t('battlegroup.backupLoadFailed')))
.finally(() => setBackupFilesLoading(false))
}
const bg = status?.battlegroup
const servers = status?.servers ?? []
return (
<div className="flex flex-col h-full gap-3 min-h-0">
{/* ── Header ───────────────────────────────────────────────────── */}
<PageHeader
title={t('serverHealth.title')}
subtitle={t('serverHealth.subtitle')}
onRefresh={refreshStatus}
loading={statusLoading}
countdown={isActive ? countdown : undefined}
/>
<HealthChips bg={bg} servers={servers} status={connStatus} />
{isInitializing && (
<div className="rounded-[var(--radius)] px-3 py-2 text-sm flex items-center gap-2 bg-warning/10 text-warning border border-warning/40 shrink-0">
<Icon name="triangle-alert" />
<span>{t('battlegroup.initWarning')}</span>
</div>
)}
{/* ── Scrollable health body ───────────────────────────────────── */}
<div className="flex-1 min-h-0 overflow-auto flex flex-col gap-3 pr-1">
{/* Health card grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<BgVmCard bg={bg} servers={servers} />
<GameReadyCard bg={bg} servers={servers} />
<ComponentHealthCard bg={bg} servers={servers} status={connStatus} />
<WebInterfacesCard status={connStatus} />
</div>
{/* Game servers */}
<HealthCard
title={t('serverHealth.gameServers')}
icon="boxes"
accessory={<span className="text-xs text-muted tabular-nums">{t('serverHealth.pods', { n: servers.length })}</span>}
>
{statusLoading && !status
? (
<div className="flex items-center gap-2 py-4 text-muted">
<Spinner size="sm" color="current" />
<span className="text-sm">{t('battlegroup.loadingStatus')}</span>
</div>
)
: (
<ServersTable
servers={servers}
isInitializing={isInitializing}
emptyMessage={status ? t('battlegroup.noGameServers') : t('battlegroup.clickRefresh')}
/>
)}
</HealthCard>
{/* ── Server Control ───────────────────────────────────────────── */}
<SectionDivider title={t('battlegroup.serverControl')} />
<div className="flex flex-wrap gap-2 shrink-0">
{ACTIONS.map((action) => (
<Button
key={action.cmd}
variant={action.danger ? 'danger-soft' : 'outline'}
onPress={() => setConfirmCmd(action)}
isDisabled={runningCmd !== null}
size="sm"
>
{t(`battlegroup.actions.${action.cmd}` as never)}
</Button>
))}
<Button variant="danger-soft" size="sm" isDisabled={runningCmd !== null} onPress={openRestore}>
{t('battlegroup.restoreLabel')}
</Button>
</div>
{/* ── Broadcasts ──────────────────────────────────────────────── */}
<SectionDivider title={t('battlegroup.broadcasts')} />
<div className="flex flex-wrap gap-3 shrink-0">
{/* Generic broadcast */}
<div className="flex flex-col gap-2 flex-1 min-w-64 rounded-[var(--radius)] border border-border bg-surface p-3">
<div className="text-xs font-semibold uppercase tracking-widest text-accent">{t('battlegroup.genericMessage')}</div>
<TextField aria-label={t('battlegroup.titlePlaceholder')}>
<Input placeholder={t('battlegroup.titlePlaceholder')} value={broadcastTitle} onChange={(e) => setBroadcastTitle(e.target.value)} />
</TextField>
<TextField aria-label={t('battlegroup.bodyPlaceholder')}>
<Input placeholder={t('battlegroup.bodyPlaceholder')} value={broadcastBody} onChange={(e) => setBroadcastBody(e.target.value)} />
</TextField>
<div className="flex items-center gap-2">
<label className="text-xs text-muted shrink-0">{t('battlegroup.durationLabel')}</label>
<NumberInput
ariaLabel={t('battlegroup.durationLabel')}
min={5}
max={300}
value={broadcastDuration}
onChange={setBroadcastDuration}
showButtons={false}
className="w-24"
/>
<div className="flex-1" />
<Button
size="sm"
isDisabled={broadcastBusy || !broadcastTitle}
onPress={async () => {
setBroadcastBusy(true)
try {
await api.broadcast.send([{ Key: 'en', Title: broadcastTitle, Body: broadcastBody }], broadcastDuration)
toast.success(t('battlegroup.broadcastSent'))
setBroadcastTitle('')
setBroadcastBody('')
}
catch (e: unknown) {
toast.danger(e instanceof Error ? e.message : String(e))
}
finally { setBroadcastBusy(false) }
}}
>
{broadcastBusy
? <Spinner size="sm" color="current" />
: (
<>
<Icon name="megaphone" />
{' '}
{t('common.send')}
</>
)}
</Button>
</div>
</div>
{/* Shutdown broadcast */}
<div className="flex flex-col gap-2 flex-1 min-w-64 rounded-[var(--radius)] border border-border bg-surface p-3">
<div className="text-xs font-semibold uppercase tracking-widest text-accent">{t('battlegroup.shutdownBroadcast')}</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted shrink-0">{t('battlegroup.shutdownType')}</label>
<Select selectedKey={shutdownType} onSelectionChange={(k) => setShutdownType(String(k))} className="flex-1" aria-label={t('battlegroup.shutdownTypeLabel')}>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
{['Restart', 'Maintenance', 'Update'].map((t) => (
<ListBox.Item key={t} id={t} textValue={t}>
{t}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted shrink-0">{t('battlegroup.shutdownDelay')}</label>
<NumberInput
ariaLabel={t('battlegroup.shutdownDelayLabel')}
min={1}
max={120}
value={shutdownDelay}
onChange={setShutdownDelay}
showButtons={false}
className="w-24"
/>
</div>
<div className="flex gap-2 mt-auto">
<Button
size="sm"
variant="danger-soft"
isDisabled={shutdownBusy}
onPress={async () => {
setShutdownBusy(true)
try {
await api.broadcast.shutdown(shutdownType, shutdownDelay)
toast.success(t('battlegroup.shutdownSent', { delay: shutdownDelay }))
}
catch (e: unknown) {
toast.danger(e instanceof Error ? e.message : String(e))
}
finally { setShutdownBusy(false) }
}}
>
{shutdownBusy
? <Spinner size="sm" color="current" />
: (
<>
<Icon name="triangle-alert" />
{' '}
{t('battlegroup.broadcastBtn')}
</>
)}
</Button>
<Button
size="sm"
variant="ghost"
isDisabled={shutdownBusy}
onPress={async () => {
setShutdownBusy(true)
try {
await api.broadcast.shutdown(shutdownType, 0, true)
toast.success(t('battlegroup.shutdownCancelled'))
}
catch (e: unknown) {
toast.danger(e instanceof Error ? e.message : String(e))
}
finally { setShutdownBusy(false) }
}}
>
{t('common.cancel')}
</Button>
</div>
</div>
</div>
{/* ── Scheduled Restarts (#145) ──────────────────────────────── */}
<SectionDivider title={t('restarts.title')} />
<ScheduledRestartsCard />
</div>
{/* ── Modals ───────────────────────────────────────────────────── */}
<ConfirmDialog
action={confirmCmd}
onConfirm={runCmd}
onClose={() => setConfirmCmd(null)}
/>
<CommandOutputModal
runningCmd={runningCmd}
cmdOutput={cmdOutput}
cmdDone={cmdDone}
lastBackupFile={lastBackupFile}
onClose={() => {
setRunningCmd(null)
setCmdOutput(null)
}}
/>
<RestoreModal
open={showRestore}
backupFiles={backupFiles}
backupFilesLoading={backupFilesLoading}
setBackupFiles={setBackupFiles}
onClose={() => setShowRestore(false)}
onRestoreComplete={(output) => {
setCmdOutput(output)
setCmdDone(true)
setRunningCmd('restore')
setShowRestore(false)
}}
/>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import type React from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Modal, Spinner } from '@heroui/react'
import { api } from '../../../api/client'
import { Icon } from '../../../dune-ui'
type CommandOutputModalProps = {
runningCmd: string | null
cmdOutput: string | null
cmdDone: boolean
lastBackupFile: string | null
onClose: () => void
}
export const CommandOutputModal: React.FC<CommandOutputModalProps> = ({
runningCmd, cmdOutput, cmdDone, lastBackupFile, onClose,
}) => {
const { t } = useTranslation()
return (
<Modal>
<Modal.Backdrop isOpen={runningCmd !== null} onOpenChange={(v) => { if (!v && cmdDone) onClose() }}>
<Modal.Container>
<Modal.Dialog>
<Modal.Header><Modal.Heading>{runningCmd ? t(`battlegroup.actions.${runningCmd}` as never) : ''}</Modal.Heading></Modal.Header>
<Modal.Body>
{!cmdDone
? (
<div className="flex flex-col items-center gap-4 py-6">
<Spinner size="lg" />
<p className="text-sm text-muted">
{t('battlegroup.runningCmd', { cmd: runningCmd?.toLowerCase() ?? '' })}
</p>
</div>
)
: (
<div className="rounded-[var(--radius)] p-3 font-mono text-xs overflow-auto max-h-60 bg-background border border-border text-success">
<pre className="m-0 whitespace-pre-wrap">{cmdOutput}</pre>
</div>
)}
</Modal.Body>
{cmdDone && (
<Modal.Footer>
{lastBackupFile && runningCmd === 'backup' && (
<a
href={api.battlegroup.backupDownloadUrl(lastBackupFile)}
download={lastBackupFile.replace('.backup', '.zip')}
className="text-sm px-3 py-1.5 rounded-[var(--radius)] inline-flex items-center gap-1.5 bg-success/10 text-success border border-success/40 no-underline hover:bg-success/20"
>
<Icon name="download" />
{' '}
{t('battlegroup.modal.download')}
</a>
)}
<Button onPress={onClose}>{t('common.close')}</Button>
</Modal.Footer>
)}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)
}

View File

@@ -0,0 +1,47 @@
import type React from 'react'
import { useTranslation } from 'react-i18next'
import { AlertDialog, Button } from '@heroui/react'
import type { ActionDef } from '../types'
type ConfirmDialogProps = {
action: ActionDef | null
onConfirm: (a: ActionDef) => void
onClose: () => void
}
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ action, onConfirm, onClose }) => {
const { t } = useTranslation()
return (
<AlertDialog.Backdrop isOpen={action !== null} onOpenChange={(v) => { if (!v) onClose() }}>
<AlertDialog.Container size="sm">
<AlertDialog.Dialog>
<AlertDialog.Header>
<AlertDialog.Icon status={action?.danger ? 'danger' : 'accent'} />
<AlertDialog.Heading>
{action ? t(`battlegroup.actions.${action.cmd}` as never) : ''}
{' '}
{t('battlegroup.confirm.serverSuffix')}
</AlertDialog.Heading>
</AlertDialog.Header>
<AlertDialog.Body>
<p className="text-sm text-muted">
{action ? t(`battlegroup.actions.${action.cmd}Msg` as never) : ''}
</p>
</AlertDialog.Body>
<AlertDialog.Footer>
<Button slot="close" variant="ghost" onPress={onClose}>{t('common.cancel')}</Button>
<Button
slot="close"
variant={action?.danger ? 'danger-soft' : 'primary'}
onPress={() => action && onConfirm(action)}
>
{t('battlegroup.confirm.confirmPrefix')}
{' '}
{action ? t(`battlegroup.actions.${action.cmd}` as never) : ''}
</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
</AlertDialog.Container>
</AlertDialog.Backdrop>
)
}

View File

@@ -0,0 +1,155 @@
import type React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Modal, Spinner, toast } from '@heroui/react'
import { api } from '../../../api/client'
import type { BackupFile } from '../../../api/client'
import { Dropzone, Icon } from '../../../dune-ui'
type RestoreModalProps = {
open: boolean
backupFiles: BackupFile[]
backupFilesLoading: boolean
setBackupFiles: (files: BackupFile[]) => void
onClose: () => void
onRestoreComplete: (output: string) => void
}
export const RestoreModal: React.FC<RestoreModalProps> = ({
open, backupFiles, backupFilesLoading, setBackupFiles, onClose, onRestoreComplete,
}) => {
const { t } = useTranslation()
const [selectedFile, setSelectedFile] = useState('')
const [restoreRunning, setRestoreRunning] = useState(false)
const [uploading, setUploading] = useState(false)
const uploadFile = async (file: File) => {
setUploading(true)
try {
const res = await api.battlegroup.backupUpload(file)
toast.success(t('battlegroup.restore.uploaded', { name: res.name }))
const updated = await api.battlegroup.backupFiles()
setBackupFiles(updated)
setSelectedFile(res.name)
}
catch (e: unknown) {
toast.danger(e instanceof Error ? e.message : String(e))
}
finally {
setUploading(false)
}
}
return (
<Modal>
<Modal.Backdrop isOpen={open} onOpenChange={(v) => { if (!v && !restoreRunning) onClose() }}>
<Modal.Container>
<Modal.Dialog className="w-[640px] max-w-[90vw]">
<Modal.CloseTrigger />
<Modal.Header><Modal.Heading>{t('battlegroup.restore.title')}</Modal.Heading></Modal.Header>
<Modal.Body>
<p className="text-sm mb-3 text-danger flex items-center gap-1.5">
<Icon name="triangle-alert" />
{' '}
{t('battlegroup.restore.warning')}
</p>
<div className="mb-3">
<Dropzone
accept=".backup,.zip"
uploading={uploading}
onSelect={uploadFile}
prompt={t('battlegroup.restore.dropzone')}
/>
</div>
{backupFilesLoading
? (
<div className="flex justify-center py-4"><Spinner /></div>
)
: backupFiles.length === 0
? (
<p className="text-sm text-muted">{t('battlegroup.restore.noBackups')}</p>
)
: (
<div className="flex flex-col gap-1">
{backupFiles.map((f) => {
const isSelected = selectedFile === f.name
return (
<label
key={f.name}
className={
'flex items-center gap-3 rounded-md px-3 py-2 cursor-pointer border '
+ (isSelected
? 'bg-success/10 border-success/40'
: 'bg-background border-border hover:border-warning/60')
}
>
<input
type="radio"
name="restore-file"
value={f.name}
checked={isSelected}
onChange={() => setSelectedFile(f.name)}
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-mono">{f.name}</div>
<div className="text-xs flex items-center gap-2 text-muted">
<span>
{(f.size_bytes / 1024 / 1024).toFixed(1)}
{' '}
MB ·
{' '}
{f.modified}
</span>
{f.has_yaml && (
<span className="px-1 rounded bg-success/10 text-success text-[10px] border border-success/30">+yaml</span>
)}
</div>
</div>
<a
href={api.battlegroup.backupDownloadUrl(f.name)}
download={f.name.replace('.backup', '.zip')}
onClick={(e) => e.stopPropagation()}
className="text-xs px-2 py-1 rounded bg-accent/10 text-accent border border-accent/30 no-underline hover:bg-accent/20"
aria-label="Download"
>
<Icon name="download" />
</a>
</label>
)
})}
</div>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" onPress={onClose} isDisabled={restoreRunning}>{t('common.cancel')}</Button>
<Button
variant="danger"
isDisabled={!selectedFile || restoreRunning || backupFilesLoading}
onPress={async () => {
setRestoreRunning(true)
try {
const res = await api.battlegroup.restore(selectedFile)
toast.success(t('battlegroup.restore.restoreCompleted'))
onRestoreComplete(res.output || '(done)')
}
catch (e: unknown) {
toast.danger(e instanceof Error ? e.message : String(e))
}
finally {
setRestoreRunning(false)
}
}}
>
{restoreRunning
? <Spinner size="sm" color="current" />
: t('battlegroup.restore.restoreBtn', { file: selectedFile ? selectedFile.slice(-20) : '' })}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)
}

View File

@@ -0,0 +1,60 @@
import type { TFunction } from 'i18next'
import type { Column } from '../../dune-ui'
export type ServerSortKey = 'map' | 'phase' | 'players' | 'queue' | 'ready' | 'dimension' | 'partition' | 'age'
export type ServerRow = {
map: string
sietch: string
dimension: number
partition: number
phase: string
ready: boolean
players: number
playerHardCap: number
queue: number
port?: number
ageSeconds?: number
}
export type BGInfo = {
name: string
title: string
phase: string
database: string
}
export type DetailedStatus = {
battlegroup: BGInfo
servers: ServerRow[]
}
export type ActionDef = {
label: string
cmd: string
danger: boolean
msg: string
}
export function getServerColumns(t: TFunction): Column<ServerSortKey>[] {
return [
{ key: 'map', label: t('battlegroup.columns.map'), isRowHeader: true },
{ key: 'phase', label: t('battlegroup.columns.phase'), width: 100 },
{ key: 'players', label: t('battlegroup.columns.players'), width: 80 },
{ key: 'queue', label: t('battlegroup.columns.queue'), width: 70 },
{ key: 'ready', label: t('battlegroup.columns.ready'), width: 70 },
{ key: 'dimension', label: t('battlegroup.columns.dim'), width: 60 },
{ key: 'partition', label: t('battlegroup.columns.part'), width: 60 },
{ key: 'age', label: t('battlegroup.columns.age'), width: 80 },
]
}
export const ACTIONS: ActionDef[] = [
{ label: 'start', cmd: 'start', danger: false, msg: 'startMsg' },
{ label: 'stop', cmd: 'stop', danger: true, msg: 'stopMsg' },
{ label: 'restart', cmd: 'restart', danger: false, msg: 'restartMsg' },
{ label: 'update', cmd: 'update', danger: false, msg: 'updateMsg' },
{ label: 'backup', cmd: 'backup', danger: false, msg: 'backupMsg' },
]
export const INIT_WARN_MS = 3 * 60 * 1000

View File

@@ -0,0 +1,29 @@
/**
* formatUptime turns an elapsed-seconds count into a compact human label
* ("73s", "15m", "1h 15m", "18d 4h"). Pure — takes the value, never reads the
* clock — so it's stable in render and trivially testable. Returns "—" for 0 /
* missing values (e.g. control planes that don't source process age).
*/
export function formatUptime(seconds?: number): string {
if (!seconds || seconds <= 0) return '—'
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m`
return `${s}s`
}
/**
* portRange collapses the running servers' UDP ports into a single label —
* "7777" for one, "77777810" for a span, "—" when none are known.
*/
export function portRange(ports: number[]): string {
const valid = ports.filter((p) => p > 0).sort((a, b) => a - b)
if (valid.length === 0) return '—'
const lo = valid[0]
const hi = valid[valid.length - 1]
return lo === hi ? `${lo}` : `${lo}${hi}`
}

View File

@@ -0,0 +1,284 @@
import { useState, useEffect, useCallback } from 'react'
import type React from 'react'
import { useTranslation } from 'react-i18next'
import {
Button,
Label,
ListBox,
ListLayout,
Modal,
Select,
Spinner,
TextField,
Virtualizer,
toast,
} from '@heroui/react'
import { api } from '../api/client'
import type { BlueprintRow, Player } from '../api/client'
import { DataTable, Dropzone, Icon, PageHeader, type Column } from '../dune-ui'
type Key = 'id' | 'owner_name' | 'name' | 'item_id' | 'pieces' | 'placeables' | 'actions'
interface BlueprintsTabProps {
isSignedIn?: boolean
}
export const BlueprintsTab: React.FC<BlueprintsTabProps> = ({ isSignedIn = true }) => {
const { t } = useTranslation()
const [blueprints, setBlueprints] = useState<BlueprintRow[]>([])
const [loading, setLoading] = useState(false)
const [showImport, setShowImport] = useState(false)
const COLUMNS: Column<Key>[] = [
{ key: 'id', label: t('blueprints.columns.id'), width: 80 },
{ key: 'owner_name', label: t('blueprints.columns.owner'), minWidth: 140 },
{ key: 'name', label: t('blueprints.columns.name'), minWidth: 200 },
{ key: 'item_id', label: t('blueprints.columns.itemId'), minWidth: 200 },
{ key: 'pieces', label: t('blueprints.columns.pieces'), width: 100 },
{ key: 'placeables', label: t('blueprints.columns.placeables'), width: 110 },
{ key: 'actions', label: '', width: 110, sortable: false },
]
const load = useCallback(() => {
Promise.resolve()
.then(() => setLoading(true))
.then(() => api.blueprints.list())
.then(setBlueprints)
.catch((e: unknown) => toast.danger(t('blueprints.failedToLoad', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setLoading(false))
}, [t])
useEffect(() => {
load()
}, [load])
return (
<div className="flex flex-col h-full gap-3 min-h-0">
{!isSignedIn && (
<div className="shrink-0 rounded-[var(--radius)] px-4 py-2 text-xs font-medium bg-danger/10 border border-danger/40 text-danger flex items-center gap-2">
<Icon name="triangle-alert" />
<span>
A
{' '}
<strong>{t('blueprints.layoutAccountStrong')}</strong>
{' '}
account is required to export or import blueprints. Sign in using the button
in the top right.
</span>
</div>
)}
<PageHeader
title={t('blueprints.title', { count: blueprints.length })}
subtitle={t('blueprints.subtitle')}
>
<Button size="sm" variant="ghost" onPress={load} isDisabled={loading}>
{loading
? (
<Spinner size="sm" color="current" />
)
: (
<>
<Icon name="refresh-cw" />
{' '}
{t('common.refresh')}
</>
)}
</Button>
<Button size="sm" onPress={() => setShowImport(true)} isDisabled={!isSignedIn}>
<Icon name="upload" />
{' '}
{t('blueprints.importBlueprint')}
</Button>
</PageHeader>
<DataTable<BlueprintRow, Key>
aria-label={t('blueprints.ariaLabel')}
className="min-h-0 max-h-full"
columns={COLUMNS}
rows={blueprints}
loading={loading}
rowId={(b) => String(b.id)}
initialSort={{ column: 'id', direction: 'ascending' }}
sortValue={(b, k) => (k === 'actions' ? '' : (b as unknown as Record<string, string | number>)[k])}
emptyState={<div className="py-8 text-center text-muted">{t('blueprints.noBlueprintsFound')}</div>}
renderCell={(b, key) => {
switch (key) {
case 'id':
return <span className="font-mono text-muted">{b.id}</span>
case 'owner_name':
return b.owner_name
case 'name':
return b.name || <span className="text-muted"></span>
case 'item_id':
return <span className="font-mono text-muted">{b.item_id}</span>
case 'pieces':
return <span className="text-muted">{b.pieces}</span>
case 'placeables':
return <span className="text-muted">{b.placeables}</span>
case 'actions':
return isSignedIn
? (
<a
href={api.blueprints.exportUrl(b.id)}
download={b.name ? `${b.name.replace(/[/\\:*?"<>|]/g, '_')}.json` : `blueprint_${b.id}.json`}
>
<Button size="sm" variant="outline" className="w-full">
<Icon name="download" />
{' '}
{t('common.export')}
</Button>
</a>
)
: (
<Button size="sm" variant="outline" className="w-full" isDisabled>
<Icon name="download" />
{' '}
{t('common.export')}
</Button>
)
}
}}
/>
<ImportModal
open={showImport}
onClose={() => setShowImport(false)}
onSuccess={() => {
setShowImport(false)
load()
}}
/>
</div>
)
}
interface ImportModalProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
function ImportModal({ open, onClose, onSuccess }: ImportModalProps) {
const { t } = useTranslation()
const [file, setFile] = useState<File | null>(null)
const [players, setPlayers] = useState<Player[]>([])
const [selectedPlayerId, setSelectedPlayerId] = useState<number | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!open) return
Promise.resolve()
.then(() => {
setFile(null)
setSelectedPlayerId(null)
})
.then(() => api.players.list())
.then(setPlayers)
.catch(() => {})
}, [open])
const selectedPlayer = players.find((p) => p.id === selectedPlayerId) ?? null
const handleSubmit = async () => {
if (!file) {
toast.warning(t('blueprints.selectFile'))
return
}
if (!selectedPlayer) {
toast.warning(t('blueprints.selectPlayer'))
return
}
setSubmitting(true)
try {
const res = await api.blueprints.import(file, selectedPlayer.id)
if (res.ok) {
toast.success(t('blueprints.importSuccess'))
onSuccess()
}
else {
toast.danger(t('blueprints.importFailed', { message: res.error ?? 'unknown error' }))
}
}
catch (e: unknown) {
toast.danger(t('blueprints.importFailed', { message: e instanceof Error ? e.message : String(e) }))
}
finally {
setSubmitting(false)
}
}
return (
<Modal>
<Modal.Backdrop isOpen={open} onOpenChange={(v) => !v && onClose()}>
<Modal.Container>
<Modal.Dialog>
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading className="text-accent">{t('blueprints.importModal.title')}</Modal.Heading>
</Modal.Header>
<Modal.Body className="flex flex-col gap-4">
<TextField>
<Label>{t('blueprints.importModal.blueprintFile')}</Label>
<Dropzone
accept=".json"
file={file}
onSelect={setFile}
prompt={t('blueprints.importModal.dropzone')}
/>
</TextField>
<TextField>
<Label>{t('blueprints.importModal.playerLabel')}</Label>
<Select
aria-label={t('blueprints.importModal.playerLabel')}
placeholder={t('blueprints.importModal.playerPlaceholder')}
selectedKey={selectedPlayerId !== null ? String(selectedPlayerId) : null}
onSelectionChange={(k) => setSelectedPlayerId(k ? Number(k) : null)}
className="w-full"
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover className="!w-[320px] !max-w-[90vw]">
<Virtualizer layout={ListLayout} layoutOptions={{ rowHeight: 36 }}>
<ListBox
aria-label={t('blueprints.importModal.playersLabel')}
className="overflow-y-auto"
style={{ height: Math.min(players.length * 36 + 8, 320) }}
items={players.map((p) => ({ id: String(p.id), name: p.name, actorId: p.id }))}
>
{(item: { id: string, name: string, actorId: number }) => (
<ListBox.Item id={item.id} textValue={item.name}>
<span className="flex items-baseline gap-2">
<span>{item.name}</span>
<span className="text-xs text-muted font-mono">
#
{item.actorId}
</span>
</span>
<ListBox.ItemIndicator />
</ListBox.Item>
)}
</ListBox>
</Virtualizer>
</Select.Popover>
</Select>
</TextField>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" slot="close">
{t('common.cancel')}
</Button>
<Button onPress={handleSubmit} isDisabled={submitting || !file || !selectedPlayer}>
{submitting ? <Spinner size="sm" color="current" /> : <Icon name="upload" />}
{t('blueprints.importModal.import')}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)
}

View File

@@ -0,0 +1,329 @@
import { useState, useEffect, useCallback } from 'react'
import type React from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Spinner, Switch, ToggleButton, ToggleButtonGroup, toast } from '@heroui/react'
import { api } from '../../../api/client'
import type { DBBackupFile, ScheduledBackups, BackupRule } from '../../../api/client'
import { Panel, SectionLabel, PageHeader, Icon, ConfirmDialog, NumberInput, TimeInput } from '../../../dune-ui'
import { TimezoneSelect } from '../../../components/TimezoneSelect'
const DOW = [0, 1, 2, 3, 4, 5, 6] // Sun..Sat
function fmtSize(b: number): string {
if (b < 1024) return `${b} B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`
return `${(b / 1024 / 1024 / 1024).toFixed(1)} GB`
}
// ── Backup schedule card (self-contained, mirrors ScheduledRestartsCard) ──────
const ScheduleCard: React.FC = () => {
const { t, i18n } = useTranslation()
const [data, setData] = useState<ScheduledBackups | null>(null)
const [enabled, setEnabled] = useState(false)
const [timezone, setTimezone] = useState('')
const [keepN, setKeepN] = useState(0)
const [rules, setRules] = useState<BackupRule[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const apply = (d: ScheduledBackups) => {
setData(d)
setEnabled(d.enabled)
setTimezone(d.timezone)
setKeepN(d.keep_n || 0)
setRules(d.rules ?? [])
}
const load = useCallback(() => {
Promise.resolve()
.then(() => setLoading(true))
.then(() => api.scheduledBackups.get())
.then(apply)
.catch((e: unknown) =>
toast.danger(t('backups.loadFailed', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setLoading(false))
}, [t])
useEffect(() => {
load()
}, [load])
const save = () => {
setSaving(true)
api.scheduledBackups.update({ enabled, timezone, rules, keep_n: keepN })
.then((res) => {
toast.success(res.ok)
load()
})
.catch((e: unknown) =>
toast.danger(t('backups.schedule.saveFailed', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setSaving(false))
}
const addRule = () => setRules((r) => [...r, { days: [...DOW], time: '04:00' }])
const removeRule = (i: number) => setRules((r) => r.filter((_, idx) => idx !== i))
const setRuleTime = (i: number, time: string) =>
setRules((r) => r.map((rule, idx) => (idx === i ? { ...rule, time } : rule)))
const setRuleDays = (i: number, days: number[]) =>
setRules((r) => r.map((rule, idx) => (idx === i ? { ...rule, days } : rule)))
const dowLabel = (d: number) =>
new Intl.DateTimeFormat(i18n.language, { weekday: 'short' }).format(new Date(Date.UTC(2023, 0, 1 + d)))
return (
<Panel>
<div className="flex items-center justify-between mb-1">
<SectionLabel>{t('backups.schedule.title')}</SectionLabel>
<Switch isSelected={enabled} onChange={setEnabled} size="sm" className="text-xs text-muted">
<Switch.Control><Switch.Thumb /></Switch.Control>
<Switch.Content>{t('backups.schedule.enable')}</Switch.Content>
</Switch>
</div>
<p className="text-xs text-muted mb-2">{t('backups.schedule.desc')}</p>
{loading
? <div className="py-3 flex justify-center"><Spinner size="sm" color="current" /></div>
: (
<>
<div className="text-sm mb-2">
{enabled && data?.next_backup
? (
<span className="text-success">
{t('backups.schedule.nextBackup', { when: new Date(data.next_backup).toLocaleString() })}
</span>
)
: <span className="text-muted">{t('backups.schedule.noneScheduled')}</span>}
</div>
{rules.length === 0 && <div className="text-xs text-muted mb-2">{t('backups.schedule.noRules')}</div>}
{rules.map((rule, i) => (
<div key={i} className="flex items-center gap-2 mb-2 flex-wrap">
<ToggleButtonGroup
selectionMode="multiple"
selectedKeys={rule.days.map(String)}
onSelectionChange={(keys) => {
const days = [...keys].map(Number).sort((a, b) => a - b)
setRuleDays(i, days)
}}
size="sm"
>
{DOW.map((d) => (
<ToggleButton key={d} id={String(d)}>{dowLabel(d)}</ToggleButton>
))}
</ToggleButtonGroup>
<TimeInput value={rule.time} onChange={(v) => setRuleTime(i, v)} ariaLabel="time" />
<Button
size="sm"
variant="ghost"
isIconOnly
aria-label={t('backups.schedule.removeRule')}
onPress={() => removeRule(i)}
>
<Icon name="x" />
</Button>
</div>
))}
<Button size="sm" variant="outline" className="mb-3" onPress={addRule}>
<Icon name="plus" />
{' '}
{t('backups.schedule.addRule')}
</Button>
<div className="flex items-center gap-4 mb-3 text-sm flex-wrap">
<label className="flex items-center gap-2">
{t('backups.schedule.keepN')}
<NumberInput
value={keepN}
onChange={setKeepN}
min={0}
ariaLabel={t('backups.schedule.keepN')}
className="w-20"
showButtons={false}
/>
<span className="text-xs text-muted">{t('backups.schedule.keepHint')}</span>
</label>
<label className="flex items-center gap-2 flex-1 min-w-[160px]">
{t('backups.schedule.timezone')}
<TimezoneSelect value={timezone} onChange={setTimezone} className="flex-1" />
</label>
</div>
<Button size="sm" onPress={save} isDisabled={saving}>
{saving ? <Spinner size="sm" color="current" /> : t('backups.schedule.save')}
</Button>
</>
)}
</Panel>
)
}
// ── Backups view ─────────────────────────────────────────────────────────────
export const BackupsView: React.FC = () => {
const { t } = useTranslation()
const [backups, setBackups] = useState<DBBackupFile[]>([])
const [loading, setLoading] = useState(true)
const [taking, setTaking] = useState(false)
const [restoreTarget, setRestoreTarget] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const load = useCallback(() => {
Promise.resolve()
.then(() => setLoading(true))
.then(() => api.dbBackups.list())
.then((res) => setBackups(res.backups ?? []))
.catch((e: unknown) =>
toast.danger(t('backups.loadFailed', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setLoading(false))
}, [t])
useEffect(() => {
load()
}, [load])
const take = () => {
setTaking(true)
api.dbBackups.take()
.then((res) => {
toast.success(t('backups.taken', { name: res.name }))
load()
})
.catch((e: unknown) =>
toast.danger(t('backups.takeFailed', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setTaking(false))
}
const doRestore = () => {
if (!restoreTarget) return
const file = restoreTarget
setRestoreTarget(null)
setBusy(true)
api.dbBackups.restore(file)
.then((res) => toast.success(res.ok))
.catch((e: unknown) =>
toast.danger(t('backups.restoreFailed', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setBusy(false))
}
const doDelete = () => {
if (!deleteTarget) return
const file = deleteTarget
setDeleteTarget(null)
setBusy(true)
api.dbBackups.remove(file)
.then((res) => {
toast.success(res.ok)
load()
})
.catch((e: unknown) =>
toast.danger(t('backups.deleteFailed', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setBusy(false))
}
return (
<div className="h-full min-h-0 flex flex-col gap-3">
<PageHeader title={t('database.sections.backups')} onRefresh={load} loading={loading} />
<div className="rounded-[var(--radius)] px-3 py-2 text-sm flex items-start gap-2 bg-warning/10 text-warning border border-warning/40 shrink-0">
<Icon name="triangle-alert" className="size-4 mt-0.5 shrink-0" />
<span>{t('backups.warning')}</span>
</div>
<div className="flex-1 min-h-0 overflow-auto flex flex-col gap-3 pr-1">
{/* Take Backup */}
<Panel>
<SectionLabel>{t('backups.take.title')}</SectionLabel>
<p className="text-xs text-muted">{t('backups.take.desc')}</p>
<div>
<Button size="sm" onPress={take} isDisabled={taking}>
{taking
? <Spinner size="sm" color="current" />
: (
<>
<Icon name="database-backup" />
{' '}
{t('backups.take.btn')}
</>
)}
</Button>
</div>
</Panel>
<ScheduleCard />
{/* Recent backups */}
<Panel>
<SectionLabel>{t('backups.recent.title')}</SectionLabel>
{loading
? <div className="py-3 flex justify-center"><Spinner size="sm" color="current" /></div>
: backups.length === 0
? <div className="text-sm text-muted py-2">{t('backups.recent.empty')}</div>
: (
<div className="flex flex-col gap-1">
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-3 px-2 text-xs uppercase tracking-wide text-muted">
<span>{t('backups.col.name')}</span>
<span className="text-right">{t('backups.col.size')}</span>
<span>{t('backups.col.modified')}</span>
<span />
</div>
{backups.map((b) => (
<div
key={b.name}
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 items-center px-2 py-1.5 rounded bg-surface border border-border/40"
>
<span className="font-mono text-sm truncate" title={b.name}>{b.name}</span>
<span className="text-sm text-muted text-right tabular-nums">{fmtSize(b.size_bytes)}</span>
<span className="text-sm text-muted">{new Date(b.modified).toLocaleString()}</span>
<div className="flex items-center gap-1">
<a href={api.dbBackups.downloadUrl(b.name)} download>
<Button size="sm" variant="ghost" isIconOnly aria-label={t('backups.download')}>
<Icon name="download" />
</Button>
</a>
<Button
size="sm"
variant="outline"
isDisabled={busy}
onPress={() => setRestoreTarget(b.name)}
>
{t('backups.restoreLabel')}
</Button>
<Button
size="sm"
variant="ghost"
isIconOnly
aria-label={t('backups.deleteLabel')}
isDisabled={busy}
onPress={() => setDeleteTarget(b.name)}
>
<Icon name="trash-2" />
</Button>
</div>
</div>
))}
</div>
)}
</Panel>
</div>
<ConfirmDialog
open={restoreTarget !== null}
title={t('backups.restoreConfirmTitle')}
description={t('backups.restoreConfirmDesc', { name: restoreTarget ?? '' })}
confirmLabel={t('backups.restoreLabel')}
onConfirm={doRestore}
onCancel={() => setRestoreTarget(null)}
/>
<ConfirmDialog
open={deleteTarget !== null}
title={t('backups.deleteConfirmTitle')}
description={t('backups.deleteConfirmDesc', { name: deleteTarget ?? '' })}
confirmLabel={t('backups.deleteLabel')}
onConfirm={doDelete}
onCancel={() => setDeleteTarget(null)}
/>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next'
import { DataTable, type Column } from '../../../dune-ui'
type TableData = { headers: string[], rows: string[][] }
export function ResultTable({ headers, rows }: TableData) {
const { t } = useTranslation()
const safeHeaders = headers ?? []
const safeRows = rows ?? []
if (safeRows.length === 0 || safeHeaders.length === 0) {
return <p className="text-sm text-muted">{t('database.noResults')}</p>
}
const columns: Column<string>[] = safeHeaders.map((h, i) => ({
key: `c${i}`,
label: h,
}))
type Row = { _id: string, values: string[] }
const items: Row[] = safeRows.map((r, i) => ({ _id: String(i), values: r ?? [] }))
return (
<DataTable<Row, string>
aria-label={t('database.resultLabel')}
className="min-h-0 max-h-full"
columns={columns}
rows={items}
rowId={(r) => r._id}
initialSort={{ column: columns[0].key, direction: 'ascending' }}
sortValue={(r, k) => {
const idx = Number(k.slice(1))
const v = r.values[idx] ?? ''
const n = Number(v)
return !isNaN(n) && v !== '' ? n : v
}}
renderCell={(r, k) => {
const idx = Number(k.slice(1))
return <span className="font-mono whitespace-nowrap">{r.values[idx] ?? ''}</span>
}}
/>
)
}

View File

@@ -0,0 +1,84 @@
import { useMemo, useState } from 'react'
import { SearchField } from '@heroui/react'
interface TableSearchInputProps {
value: string
onChange: (v: string) => void
onRun: () => void
tableNames: string[]
ariaLabel: string
placeholder: string
}
export function TableSearchInput(
{ value, onChange, onRun, tableNames, ariaLabel, placeholder }: TableSearchInputProps,
) {
const [open, setOpen] = useState(false)
const filtered = useMemo(() => {
const q = value.toLowerCase().trim()
if (!q) return tableNames.slice(0, 40)
return tableNames.filter((n) => n.toLowerCase().includes(q))
}, [value, tableNames])
const pick = (name: string) => {
onChange(name)
setOpen(false)
}
return (
<div
className="relative flex-1 max-w-md"
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setOpen(false)
}
}}
>
<SearchField
className="w-full"
value={value}
onChange={(v) => {
onChange(v)
setOpen(true)
}}
onFocus={() => setOpen(true)}
aria-label={ariaLabel}
>
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input
placeholder={placeholder}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setOpen(false)
onRun()
}
if (e.key === 'Escape') setOpen(false)
if (e.key === 'ArrowDown') setOpen(true)
}}
/>
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
{open && filtered.length > 0 && (
<div className="absolute z-50 w-full mt-1 rounded-[var(--radius)] border border-border bg-surface overflow-y-auto max-h-52 shadow-lg">
{filtered.map((n) => (
<button
key={n}
type="button"
className="w-full text-left px-3 py-1.5 text-xs cursor-pointer hover:bg-surface-hover"
onMouseDown={(e) => {
e.preventDefault()
pick(n)
}}
>
<span className="text-muted mr-0.5">dune.</span>
<span className="font-mono">{n}</span>
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,39 @@
import { createTheme } from '@uiw/codemirror-themes'
import { tags as hlTags } from '@lezer/highlight'
export const duneTheme = createTheme({
theme: 'dark',
settings: {
background: 'var(--field-background)',
foreground: 'var(--field-foreground)',
caret: 'var(--accent)',
selection: 'rgba(201,130,10,0.25)',
selectionMatch: 'rgba(201,130,10,0.12)',
lineHighlight: 'var(--surface)',
gutterBackground: 'var(--surface)',
gutterForeground: 'var(--muted)',
gutterBorder: 'transparent',
gutterActiveForeground: 'var(--accent)',
},
styles: [
{ tag: hlTags.comment, color: 'var(--muted)', fontStyle: 'italic' },
{ tag: hlTags.lineComment, color: 'var(--muted)', fontStyle: 'italic' },
{ tag: hlTags.blockComment, color: 'var(--muted)', fontStyle: 'italic' },
{ tag: hlTags.keyword, color: 'var(--accent)', fontWeight: 'bold' },
{ tag: hlTags.definitionKeyword, color: 'var(--accent)' },
{ tag: hlTags.modifier, color: 'var(--accent)' },
{ tag: hlTags.operatorKeyword, color: 'var(--accent)' },
{ tag: hlTags.string, color: 'var(--success)' },
{ tag: hlTags.number, color: 'var(--warning)' },
{ tag: hlTags.bool, color: 'var(--warning)' },
{ tag: hlTags.null, color: 'var(--danger)' },
{ tag: hlTags.operator, color: 'var(--foreground)' },
{ tag: hlTags.punctuation, color: 'var(--muted)' },
{ tag: hlTags.name, color: 'var(--foreground)' },
{ tag: hlTags.typeName, color: 'var(--warning)' },
{ tag: hlTags.function(hlTags.variableName), color: 'var(--warning)' },
{ tag: hlTags.special(hlTags.name), color: 'var(--accent)' },
],
})
export type Section = 'backups' | 'tables' | 'describe' | 'sample' | 'search' | 'sql'

View File

@@ -0,0 +1,315 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type React from 'react'
import { useTranslation } from 'react-i18next'
import CodeMirror from '@uiw/react-codemirror'
import { sql as sqlLang, PostgreSQL } from '@codemirror/lang-sql'
import { keymap } from '@codemirror/view'
import { Prec } from '@codemirror/state'
import { acceptCompletion } from '@codemirror/autocomplete'
import { Button, SearchField, Spinner, toast } from '@heroui/react'
import { api } from '../../api/client'
import { Icon, LoadingState, NumberInput, PageHeader, SideNav } from '../../dune-ui'
import { duneTheme, type Section } from './constants'
import { ResultTable } from './components/ResultTable'
import { TableSearchInput } from './components/TableSearchInput'
import { BackupsView } from './components/BackupsView'
type TableData = { headers: string[], rows: string[][] }
interface DatabaseTabProps {
showSubnav?: boolean
section?: Section
onSectionChange?: (s: Section) => void
}
export const DatabaseTab: React.FC<DatabaseTabProps> = ({
section = 'backups',
onSectionChange,
showSubnav,
}) => {
const { t } = useTranslation()
const SECTIONS = useMemo<{ key: Section, label: string }[]>(() => [
{ key: 'backups', label: t('database.sections.backups') },
{ key: 'tables', label: t('database.sections.tables') },
{ key: 'describe', label: t('database.sections.describe') },
{ key: 'sample', label: t('database.sections.sample') },
{ key: 'search', label: t('database.sections.search') },
{ key: 'sql', label: t('database.sections.sql') },
], [t])
const [tableInput, setTableInput] = useState('')
const [limitInput, setLimitInput] = useState(20)
const [searchInput, setSearchInput] = useState('')
const [sqlInput, setSqlInput] = useState('')
const [result, setResult] = useState<TableData | null>(null)
const [truncated, setTruncated] = useState(false)
const [tableNames, setTableNames] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const sqlExtension = useMemo(() => sqlLang({
dialect: PostgreSQL,
upperCaseKeywords: true,
schema: Object.fromEntries(tableNames.map((n) => [n, []])),
defaultSchema: 'dune',
}), [tableNames])
// Promise-chain form (not async) so react-hooks/set-state-in-effect does not
// flag the useEffect that calls it — matches the BasesTab pattern.
const fetchTables = useCallback(() => {
Promise.resolve()
.then(() => {
setLoading(true)
setResult(null)
setTruncated(false)
setError(null)
})
.then(() => api.database.tables())
.then((rows) => {
setTableNames(rows.map((r) => r.name))
setResult({
headers: [t('database.tableColumn'), t('database.rowsColumn')],
rows: rows.map((r) => [r.name, String(r.row_count)]),
})
})
.catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e)
setError(msg)
toast.danger(t('database.failed', { message: msg }))
})
.finally(() => setLoading(false))
}, [t])
// Reset results and re-fetch whenever the section changes (driven by the left nav).
useEffect(() => {
setTruncated(false) // eslint-disable-line react-hooks/set-state-in-effect
setError(null)
setResult(null)
if (section === 'tables') fetchTables()
}, [section, fetchTables])
const run = useCallback(async () => {
if (section === 'tables') {
fetchTables()
return
}
setLoading(true)
setResult(null)
setTruncated(false)
setError(null)
try {
if (section === 'describe') {
if (!tableInput.trim()) {
toast.warning(t('database.enterTableName'))
return
}
const r = await api.database.describe(tableInput.trim())
setResult({
headers: [t('database.columnColumn'), t('database.typeColumn'), t('database.nullableColumn')],
rows: r.columns.map((c) => [c.name, c.data_type, c.nullable]),
})
}
else if (section === 'sample') {
if (!tableInput.trim()) {
toast.warning(t('database.enterTableName'))
return
}
const r = await api.database.sample(tableInput.trim(), limitInput)
setResult({ headers: r.headers, rows: r.rows })
}
else if (section === 'search') {
if (!searchInput.trim()) {
toast.warning(t('database.enterSearchTerm'))
return
}
const r = await api.database.search(searchInput.trim())
setResult({ headers: r.headers, rows: r.rows })
}
else {
if (!sqlInput.trim()) {
toast.warning(t('database.enterSQL'))
return
}
const r = await api.database.sql(sqlInput.trim())
setResult({ headers: r.headers, rows: r.rows })
setTruncated(r.truncated)
}
}
catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
setError(msg)
toast.danger(t('database.failed', { message: msg }))
}
finally {
setLoading(false)
}
}, [section, fetchTables, limitInput, searchInput, sqlInput, tableInput, t])
const editorKeymap = useMemo(() => [
Prec.highest(keymap.of([
{
key: 'Mod-Enter',
run: () => {
void run()
return true
},
},
// Must be Prec.highest to beat basicSetup's indent binding
{ key: 'Tab', run: acceptCompletion },
])),
], [run])
const activeLabel = SECTIONS.find((s) => s.key === section)?.label ?? ''
const innerContent = (
<>
<PageHeader title={activeLabel}>
<Button
size="sm"
variant="ghost"
isIconOnly
onPress={() => void run()}
isDisabled={loading}
aria-label={t('database.refreshLabel')}
>
{loading ? <Spinner size="sm" color="current" /> : <Icon name="refresh-cw" />}
</Button>
</PageHeader>
{(section === 'describe' || section === 'sample') && (
<div className="flex items-center gap-3 shrink-0">
<TableSearchInput
value={tableInput}
onChange={setTableInput}
onRun={() => void run()}
tableNames={tableNames}
ariaLabel={t('database.tableNameLabel')}
placeholder={t('database.tablePlaceholder')}
/>
{section === 'sample' && (
<NumberInput
ariaLabel={t('database.limitLabel')}
min={1}
max={1000}
value={limitInput}
onChange={setLimitInput}
showButtons={false}
className="w-28"
/>
)}
<Button onPress={() => void run()} isDisabled={loading} size="sm">
{loading ? <Spinner size="sm" color="current" /> : <Icon name="play" />}
{' '}
{t('database.runBtn')}
</Button>
</div>
)}
{section === 'search' && (
<div className="flex items-center gap-3 shrink-0">
<SearchField
className="flex-1 max-w-md"
value={searchInput}
onChange={setSearchInput}
aria-label={t('database.searchLabel')}
>
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input
placeholder={t('database.searchPlaceholder')}
onKeyDown={(e) => e.key === 'Enter' && void run()}
/>
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
<Button onPress={() => void run()} isDisabled={loading} size="sm">
{loading ? <Spinner size="sm" color="current" /> : <Icon name="search" />}
{' '}
Search
</Button>
</div>
)}
{section === 'sql' && (
<div className="flex flex-col gap-2 shrink-0">
<div
className="rounded-[var(--radius)] overflow-hidden border"
style={{ borderColor: 'var(--field-border)' }}
>
<CodeMirror
value={sqlInput}
onChange={setSqlInput}
extensions={editorKeymap.concat(sqlExtension)}
theme={duneTheme}
height="140px"
basicSetup={{
lineNumbers: true,
foldGutter: false,
autocompletion: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
}}
placeholder={t('database.sqlPlaceholder')}
/>
</div>
<div className="flex items-center gap-3">
<Button onPress={() => void run()} isDisabled={loading} size="sm">
{loading ? <Spinner size="sm" color="current" /> : <Icon name="play" />}
{' '}
{t('database.runQuery')}
</Button>
<span className="text-xs text-muted">{t('database.runHint')}</span>
</div>
</div>
)}
{loading && (
<LoadingState size="md" className="shrink-0" />
)}
{error && !loading && (
<div className="rounded-[var(--radius)] p-4 bg-danger/10 border border-danger/40 text-danger shrink-0">
<strong>{t('common.error')}</strong>
{' '}
{error}
</div>
)}
{result && !loading && !error && (
<div className="flex-1 min-h-0 flex flex-col gap-1">
<ResultTable headers={result.headers} rows={result.rows} />
{truncated && (
<p className="text-xs text-muted shrink-0">{t('database.rowsLimited')}</p>
)}
</div>
)}
</>
)
// The Backups section is self-contained (loads its own data); every other
// section shares the query/inspect shell above.
const body = section === 'backups' ? <BackupsView /> : innerContent
if (showSubnav) {
return (
<div className="h-full min-h-0 flex gap-3">
<SideNav
title={t('database.sideNavTitle')}
items={SECTIONS}
active={section ?? 'backups'}
onSelect={(key) => onSectionChange?.(key)}
/>
<div className="flex-1 min-h-0 flex flex-col gap-3">
{body}
</div>
</div>
)
}
return (
<div className="h-full min-h-0 flex flex-col gap-3">
{body}
</div>
)
}

View File

@@ -0,0 +1,227 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import type React from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Spinner, toast } from '@heroui/react'
import { api, ApiError } from '../api/client'
import type { DirectorConfig } from '../api/client'
import { PageHeader, Panel, SectionLabel, Icon, FieldInput, FieldSelect } from '../dune-ui'
// ── field-type inference (#157) ──────────────────────────────────────────────
// The director config is untyped INI text, so we infer an editor from the value
// + comment (data-driven, no hardcoded enum tables): booleans → a dropdown,
// numbers → a number input, and enums from either a "Alternatives: a, b, c"
// comment or the distinct values used across the [InstancingModes] section.
const numberRe = /^-?\d+(\.\d+)?$/
function parseAlternatives(comment?: string): string[] {
const m = comment?.match(/alternatives?:\s*(.+)/i)
return m ? m[1].split(',').map((s) => s.trim()).filter(Boolean) : []
}
type FieldKind = { kind: 'bool' } | { kind: 'number' } | { kind: 'enum', options: string[] } | { kind: 'text' }
function fieldKind(
section: string, value: string, comment: string | undefined, instancingOptions: string[],
): FieldKind {
if (section === 'InstancingModes' && instancingOptions.length > 1) return { kind: 'enum', options: instancingOptions }
const alt = parseAlternatives(comment)
if (alt.length > 1) return { kind: 'enum', options: alt }
const v = value.trim().toLowerCase()
if (v === 'true' || v === 'false') return { kind: 'bool' }
if (numberRe.test(value.trim())) return { kind: 'number' }
return { kind: 'text' }
}
const DirectorEditor: React.FC<{
kind: FieldKind
value: string
onChange: (v: string) => void
}> = ({ kind, value, onChange }) => {
if (kind.kind === 'bool') {
return (
<FieldSelect
className="w-full"
value={value.trim().toLowerCase()}
onChange={onChange}
options={['true', 'false']}
/>
)
}
if (kind.kind === 'enum') {
// Keep the current value selectable even if it isn't in the derived option set.
const opts = kind.options.includes(value) ? kind.options : [value, ...kind.options]
return (
<FieldSelect
className="w-full"
value={value}
onChange={onChange}
options={opts}
/>
)
}
if (kind.kind === 'number') {
return <FieldInput type="number" className="w-full" value={value} onChange={onChange} />
}
return <FieldInput className="w-full" value={value} onChange={onChange} />
}
// DirectorTab (#147): view/edit the Battlegroup Director config
// (director_config.ini). [InstancingModes] controls map persistence; [Database]
// and [RMQ*] are read-only (launch-overridden + secrets). AMP control plane only.
export const DirectorTab: React.FC = () => {
const { t } = useTranslation()
const [data, setData] = useState<DirectorConfig | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [unsupported, setUnsupported] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState<Map<string, string>>(new Map())
const load = useCallback(() => {
Promise.resolve()
.then(() => {
setLoading(true)
setError(null)
setUnsupported(false)
})
.then(() => api.director.get())
.then((d) => {
setData(d)
setPending(new Map())
})
.catch((e: unknown) => {
if (e instanceof ApiError && e.status === 501) setUnsupported(true)
else setError(e instanceof Error ? e.message : String(e))
})
.finally(() => setLoading(false))
}, [])
useEffect(() => {
load()
}, [load])
// The [InstancingModes] section's keys all share one enum domain (map →
// instancing mode), so its distinct values ARE the option set for each key.
const instancingOptions = useMemo(() => {
const sec = data?.sections.find((s) => s.name === 'InstancingModes')
if (!sec) return []
return Array.from(new Set(sec.lines.map((l) => l.value.trim()).filter(Boolean)))
}, [data])
const pk = (section: string, key: string) => `${section}|${key}`
const setVal = (section: string, key: string, value: string) =>
setPending((prev) => {
const n = new Map(prev)
n.set(pk(section, key), value)
return n
})
const save = () => {
if (pending.size === 0) return
const updates: Record<string, Record<string, string>> = {}
for (const [k, v] of pending) {
const [section, key] = k.split('|')
if (!updates[section]) updates[section] = {}
updates[section][key] = v
}
setSaving(true)
api.director.update(updates)
.then((res) => {
toast.success(res.ok)
load()
})
.catch((e: unknown) =>
toast.danger(t('director.saveFailed', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setSaving(false))
}
if (loading) {
return (
<div className="flex items-center justify-center h-full gap-2 text-muted">
<Spinner size="sm" color="current" />
<span className="text-sm">{t('director.loading')}</span>
</div>
)
}
if (unsupported) {
return (
<div className="flex flex-col h-full gap-3">
<PageHeader title={t('director.title')} />
<div className="text-sm text-muted py-8 text-center">{t('director.unsupported')}</div>
</div>
)
}
if (error) {
return (
<div className="flex flex-col h-full gap-3">
<PageHeader title={t('director.title')} />
<div className="rounded px-4 py-3 text-sm bg-danger/10 border border-danger/40 text-danger">{error}</div>
</div>
)
}
const dirty = pending.size
return (
<div className="flex flex-col h-full gap-3 min-h-0">
<PageHeader title={t('director.title')} subtitle={t('director.subtitle')}>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onPress={load} isDisabled={loading || saving}>
<Icon name="refresh-cw" />
</Button>
<Button size="sm" onPress={save} isDisabled={dirty === 0 || saving}>
{saving
? <Spinner size="sm" color="current" />
: dirty > 0 ? t('director.saveWithCount', { count: dirty }) : t('director.save')}
</Button>
</div>
</PageHeader>
<p className="text-xs text-warning shrink-0">{t('director.restartNote')}</p>
{data?.path && <p className="text-xs text-muted shrink-0 font-mono truncate">{data.path}</p>}
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-4 pb-6 pr-1">
{data?.sections.map((sec) => (
<Panel key={sec.name}>
<div className="flex items-center gap-2 mb-2">
<SectionLabel>{sec.name}</SectionLabel>
{sec.read_only && (
<span className="text-xs text-muted border border-border rounded px-1.5 py-0.5">
{t('director.readOnly')}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
{sec.lines.map((line) => {
const editable = !sec.read_only && !line.secret
const cur = pending.get(pk(sec.name, line.key)) ?? line.value
return (
<div
key={line.key}
className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] items-center gap-3 text-sm"
>
<div className="min-w-0">
<div className="text-foreground truncate" title={line.key}>{line.key}</div>
{line.comment && <div className="text-xs text-muted truncate" title={line.comment}>{line.comment}</div>}
</div>
{editable
? (
<DirectorEditor
kind={fieldKind(sec.name, line.value, line.comment, instancingOptions)}
value={cur}
onChange={(v) => setVal(sec.name, line.key, v)}
/>
)
: <span className="text-muted font-mono truncate">{line.secret ? '••••••••' : line.value}</span>}
</div>
)
})}
</div>
</Panel>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,284 @@
import { useState, useEffect, useCallback } from 'react'
import type React from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Chip, Modal, Spinner, toast } from '@heroui/react'
import { api } from '../api/client'
import type { GuildSummary, GuildDetail } from '../api/client'
import { DataTable, Icon, PageHeader, SectionLabel, type Column } from '../dune-ui'
type Key = 'name' | 'faction' | 'members' | 'description' | 'actions'
// Faction names are the stable dune.factions enum (Atreides/Harkonnen/None/
// Smuggler), so colour-coding by name is safe. Unknown/None → default.
const FACTION_COLOR: Record<string, 'accent' | 'danger' | 'warning' | 'default'> = {
Atreides: 'accent',
Harkonnen: 'danger',
Smuggler: 'warning',
}
// Confirmed guild role ids (dune guild procs): 100 = admin, 50 = member.
const ROLE_ADMIN = 100
const ROLE_MEMBER = 50
interface GuildsTabProps {
isSignedIn?: boolean
}
export const GuildsTab: React.FC<GuildsTabProps> = ({ isSignedIn = true }) => {
const { t } = useTranslation()
const [guilds, setGuilds] = useState<GuildSummary[]>([])
const [loading, setLoading] = useState(false)
const [detail, setDetail] = useState<GuildDetail | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [open, setOpen] = useState(false)
const [editName, setEditName] = useState('')
const [editDesc, setEditDesc] = useState('')
const [saving, setSaving] = useState(false)
const [roleBusy, setRoleBusy] = useState(false)
const load = useCallback(() => {
Promise.resolve()
.then(() => setLoading(true))
.then(() => api.guilds.list())
.then(setGuilds)
.catch((e: unknown) =>
toast.danger(t('guilds.failedToLoad', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setLoading(false))
}, [t])
useEffect(() => {
load()
}, [load])
const applyDetail = (d: GuildDetail) => {
setDetail(d)
setEditName(d.name)
setEditDesc(d.description)
}
const openDetail = (id: number) => {
setOpen(true)
setDetail(null)
setDetailLoading(true)
api.guilds.get(id)
.then(applyDetail)
.catch((e: unknown) =>
toast.danger(t('guilds.failedToLoad', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setDetailLoading(false))
}
const save = () => {
if (!detail) return
setSaving(true)
api.guilds.update(detail.guild_id, { name: editName.trim(), description: editDesc })
.then((d) => {
applyDetail(d)
toast.success(t('guilds.saved'))
load()
})
.catch((e: unknown) =>
toast.danger(t('guilds.saveFailed', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setSaving(false))
}
const makeAdmin = (playerId: number) => {
if (!detail) return
setRoleBusy(true)
api.guilds.setRole(detail.guild_id, playerId, ROLE_ADMIN)
.then(() => api.guilds.get(detail.guild_id))
.then((d) => {
applyDetail(d)
toast.success(t('guilds.roleChanged'))
})
.catch((e: unknown) =>
toast.danger(t('guilds.roleChangeFailed', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setRoleBusy(false))
}
const roleLabel = (id: number) =>
id === ROLE_ADMIN ? t('guilds.roleAdmin') : id === ROLE_MEMBER ? t('guilds.roleMember') : t('guilds.roleN', { id })
const COLUMNS: Column<Key>[] = [
{ key: 'name', label: t('guilds.columns.name'), minWidth: 200 },
{ key: 'faction', label: t('guilds.columns.faction'), width: 150 },
{ key: 'members', label: t('guilds.columns.members'), width: 110 },
{ key: 'description', label: t('guilds.columns.description'), minWidth: 240 },
{ key: 'actions', label: '', width: 120, sortable: false },
]
const inputCls = 'w-full bg-surface text-foreground border border-border rounded px-2 py-1 text-sm'
return (
<div className="flex flex-col h-full gap-3 min-h-0">
<PageHeader title={t('guilds.title', { count: guilds.length })} subtitle={t('guilds.subtitle')}>
<Button size="sm" variant="ghost" onPress={load} isDisabled={loading}>
{loading
? <Spinner size="sm" color="current" />
: (
<>
<Icon name="refresh-cw" />
{' '}
{t('common.refresh')}
</>
)}
</Button>
</PageHeader>
<DataTable<GuildSummary, Key>
aria-label={t('guilds.title', { count: guilds.length })}
className="min-h-0 max-h-full"
columns={COLUMNS}
rows={guilds}
loading={loading}
rowId={(g) => String(g.guild_id)}
initialSort={{ column: 'name', direction: 'ascending' }}
sortValue={(g, k) => {
switch (k) {
case 'name': return g.name
case 'faction': return g.faction_name
case 'members': return g.member_count
case 'description': return g.description
default: return ''
}
}}
emptyState={<div className="py-8 text-center text-muted">{t('guilds.empty')}</div>}
renderCell={(g, key) => {
switch (key) {
case 'name':
return g.name || <span className="text-muted"></span>
case 'faction':
return (
<Chip size="sm" variant="soft" color={FACTION_COLOR[g.faction_name] ?? 'default'}>
{g.faction_name || '—'}
</Chip>
)
case 'members':
return <span className="text-muted">{g.member_count}</span>
case 'description':
return g.description
? <span className="text-muted">{g.description}</span>
: <span className="text-muted"></span>
case 'actions':
return (
<Button size="sm" variant="outline" className="w-full" onPress={() => openDetail(g.guild_id)}>
<Icon name="users" />
{' '}
{isSignedIn ? t('guilds.manage') : t('guilds.view')}
</Button>
)
}
}}
/>
<Modal>
<Modal.Backdrop isOpen={open} onOpenChange={(v) => !v && setOpen(false)}>
<Modal.Container size="lg" scroll="outside">
<Modal.Dialog className="max-h-[85vh] flex flex-col">
<Modal.CloseTrigger />
<Modal.Header>
<div className="flex items-baseline gap-3 flex-wrap">
<Modal.Heading className="text-accent">{detail?.name || t('guilds.title', { count: 0 })}</Modal.Heading>
{detail && (
<Chip size="sm" variant="soft" color={FACTION_COLOR[detail.faction_name] ?? 'default'}>
{detail.faction_name || '—'}
</Chip>
)}
</div>
</Modal.Header>
<Modal.Body className="flex flex-col gap-4 overflow-y-auto">
{detailLoading && (
<div className="flex items-center justify-center py-8 gap-2 text-muted">
<Spinner size="sm" color="current" />
</div>
)}
{!detailLoading && detail && (
<>
{isSignedIn
? (
<div className="flex flex-col gap-3">
<SectionLabel>{t('guilds.editGuild')}</SectionLabel>
<div>
<label className="text-xs text-muted">{t('guilds.nameLabel')}</label>
<input
className={inputCls}
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted">{t('guilds.descLabel')}</label>
<textarea
className={inputCls}
rows={2}
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
/>
</div>
<div>
<Button size="sm" onPress={save} isDisabled={saving || editName.trim() === ''}>
{saving ? <Spinner size="sm" color="current" /> : t('guilds.save')}
</Button>
</div>
</div>
)
: detail.description && <p className="text-sm text-muted">{detail.description}</p>}
<div>
<SectionLabel>{t('guilds.members')}</SectionLabel>
{detail.members.length === 0
? <div className="text-xs text-muted py-1">{t('guilds.noMembers')}</div>
: (
<div className="mt-1">
{detail.members.map((m) => (
<div
key={m.player_id}
className="flex items-center justify-between py-1.5 border-b border-border/40 text-sm gap-2"
>
<span className="text-foreground flex-1 truncate">{m.character_name}</span>
<Chip size="sm" variant="soft" color={m.role_id === ROLE_ADMIN ? 'accent' : 'default'}>
{roleLabel(m.role_id)}
</Chip>
{isSignedIn && m.role_id !== ROLE_ADMIN && (
<Button
size="sm"
variant="outline"
isDisabled={roleBusy}
onPress={() => makeAdmin(m.player_id)}
>
{t('guilds.makeAdmin')}
</Button>
)}
</div>
))}
</div>
)}
</div>
<div>
<SectionLabel>{t('guilds.invites')}</SectionLabel>
{detail.invites.length === 0
? <div className="text-xs text-muted py-1">{t('guilds.noInvites')}</div>
: (
<div className="mt-1">
{detail.invites.map((iv) => (
<div
key={iv.invite_id}
className="flex items-center justify-between py-1.5 border-b border-border/40 text-sm"
>
<span className="text-foreground">{iv.character_name}</span>
<span className="text-xs text-muted">{t('guilds.invitedBy', { name: iv.sender_name })}</span>
</div>
))}
</div>
)}
</div>
</>
)}
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,168 @@
import { useState, useEffect, useCallback } from 'react'
import type React from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Chip, Spinner, toast } from '@heroui/react'
import { api } from '../api/client'
import type { LandsraadOverview, LandsraadTask } from '../api/client'
import { DataTable, Icon, PageHeader, Panel, SectionLabel, type Column } from '../dune-ui'
type TaskKey = 'board_index' | 'house' | 'goal_amount' | 'completed' | 'sysselraad'
const Field: React.FC<{ label: string, value: string }> = ({ label, value }) => (
<div>
<div className="text-xs text-muted">{label}</div>
<div className="text-foreground">{value}</div>
</div>
)
export const LandsraadTab: React.FC = () => {
const { t } = useTranslation()
const [data, setData] = useState<LandsraadOverview | null>(null)
const [loading, setLoading] = useState(false)
const load = useCallback(() => {
Promise.resolve()
.then(() => setLoading(true))
.then(() => api.landsraad.get())
.then(setData)
.catch((e: unknown) =>
toast.danger(t('landsraad.failedToLoad', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setLoading(false))
}, [t])
useEffect(() => {
load()
}, [load])
const term = data?.term ?? null
const decrees = data?.decrees ?? []
const tasks = data?.tasks ?? []
const fmtDate = (s: string) => {
const d = new Date(s)
return Number.isNaN(d.getTime()) ? s : d.toLocaleString()
}
const dash = (s: string) => s || '—'
const TASK_COLUMNS: Column<TaskKey>[] = [
{ key: 'board_index', label: t('landsraad.tasks.index'), width: 70 },
{ key: 'house', label: t('landsraad.tasks.house'), minWidth: 160 },
{ key: 'goal_amount', label: t('landsraad.tasks.goal'), width: 120 },
{ key: 'completed', label: t('landsraad.tasks.completed'), width: 120 },
{ key: 'sysselraad', label: t('landsraad.tasks.sysselraad'), width: 120 },
]
return (
<div className="flex flex-col h-full gap-3 min-h-0">
<PageHeader title={t('landsraad.title')} subtitle={t('landsraad.subtitle')}>
<Button size="sm" variant="ghost" onPress={load} isDisabled={loading}>
{loading
? <Spinner size="sm" color="current" />
: (
<>
<Icon name="refresh-cw" />
{' '}
{t('common.refresh')}
</>
)}
</Button>
</PageHeader>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-4 pb-6 pr-1">
<Panel>
<SectionLabel>{t('landsraad.currentTerm')}</SectionLabel>
{term
? (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mt-2 text-sm">
<Field label={t('landsraad.term.id')} value={`#${term.term_id}`} />
<Field
label={t('landsraad.term.window')}
value={`${fmtDate(term.start_time)}${fmtDate(term.end_time)}`}
/>
<Field label={t('landsraad.term.reigning')} value={dash(term.reigning_faction)} />
<Field label={t('landsraad.term.activeDecree')} value={dash(term.active_decree)} />
<Field label={t('landsraad.term.electedDecree')} value={dash(term.elected_decree)} />
<Field label={t('landsraad.term.winning')} value={dash(term.winning_faction)} />
</div>
{term.test_term && (
<Chip size="sm" variant="soft" color="warning" className="mt-2">{t('landsraad.testTerm')}</Chip>
)}
</>
)
: <div className="text-xs text-muted mt-2">{t('landsraad.noTerm')}</div>}
</Panel>
<Panel>
<SectionLabel>{t('landsraad.decrees')}</SectionLabel>
<div className="text-xs text-muted mb-2">{t('landsraad.decreesDesc')}</div>
{decrees.length === 0
? <div className="text-xs text-muted">{t('landsraad.noDecrees')}</div>
: (
<div className="mt-1">
{decrees.map((d) => (
<div
key={d.id}
className="flex items-center justify-between py-1.5 border-b border-border/40 text-sm"
>
<span className="text-foreground">{d.name}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted">{t('landsraad.weight', { weight: d.weight })}</span>
<Chip size="sm" variant="soft" color={d.disabled ? 'danger' : 'success'}>
{d.disabled ? t('landsraad.disabled') : t('landsraad.enabled')}
</Chip>
</div>
</div>
))}
</div>
)}
</Panel>
<div>
<SectionLabel>{t('landsraad.taskBoard')}</SectionLabel>
<div className="text-xs text-muted mb-2">{t('landsraad.taskBoardDesc')}</div>
<DataTable<LandsraadTask, TaskKey>
aria-label={t('landsraad.taskBoard')}
className="min-h-0"
columns={TASK_COLUMNS}
rows={tasks}
loading={loading}
rowId={(tk) => String(tk.id)}
initialSort={{ column: 'board_index', direction: 'ascending' }}
sortValue={(tk, k) => {
switch (k) {
case 'board_index': return tk.board_index
case 'house': return tk.house
case 'goal_amount': return tk.goal_amount
case 'completed': return tk.completed ? 1 : 0
case 'sysselraad': return tk.sysselraad ? 1 : 0
default: return ''
}
}}
emptyState={<div className="py-8 text-center text-muted">{t('landsraad.noTasks')}</div>}
renderCell={(tk, key) => {
switch (key) {
case 'board_index':
return <span className="font-mono text-muted">{tk.board_index}</span>
case 'house':
return tk.house || <span className="text-muted"></span>
case 'goal_amount':
return <span className="text-muted">{tk.goal_amount.toLocaleString()}</span>
case 'completed':
return (
<Chip size="sm" variant="soft" color={tk.completed ? 'success' : 'default'}>
{tk.completed ? t('landsraad.tasks.done') : t('landsraad.tasks.open')}
</Chip>
)
case 'sysselraad':
return tk.sysselraad
? <Chip size="sm" variant="soft" color="accent">{t('landsraad.tasks.yes')}</Chip>
: <span className="text-muted"></span>
}
}}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,188 @@
import { useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Checkbox, SearchField } from '@heroui/react'
import { Icon, Panel, SectionLabel } from '../../../dune-ui'
import { LIVE_TYPES, CATEGORY_GROUPS, CAT_COLOR, TYPE_LABELS, ICON_POS, HEATMAP_BOUNDS, HEATMAP_TYPES, HEATMAP_COLORS } from '../constants'
import { filterKey, heatmapFilterKey } from '../utils'
import { SpriteIcon } from './SpriteIcon'
import type { FilterPanelProps } from '../types'
export function FilterPanel({
filter, onToggle, onClear, spawns, mapKey, heatmapMode, onHeatmapToggle,
}: FilterPanelProps) {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
const typesByCategory = useMemo(() => {
const map: Record<string, Map<string, { label: string, count: number }>> = {}
spawns.forEach((s) => {
const cat = s.category
if (!map[cat]) map[cat] = new Map()
const key = filterKey(s.type)
const label = TYPE_LABELS[key] ?? s.label ?? s.type.replace(/_/g, ' ')
const existing = map[cat].get(key)
map[cat].set(key, { label, count: (existing?.count ?? 0) + 1 })
})
return map
}, [spawns])
const LIVE_LABELS: Record<string, string> = {
players: t('liveMap.players'),
vehicles: t('liveMap.vehicles'),
bases: t('liveMap.filterBases'),
}
type TypeRowProps = { typeKey: string, label: string, count: number, category: string }
function TypeRow({ typeKey, label, count, category }: TypeRowProps) {
const isOn = filter[typeKey] ?? false
return (
<Checkbox
isSelected={isOn}
onChange={() => onToggle(typeKey, isOn)}
className="flex items-center gap-2 py-1.5 px-3 hover:bg-surface-secondary rounded-[var(--radius)] w-full max-w-none"
>
<Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>
<SpriteIcon type={typeKey} size={18} />
{!ICON_POS[typeKey] && (
<span style={{ color: CAT_COLOR[category] }} className="shrink-0"></span>
)}
<span className="flex-1 text-xs text-foreground truncate">{label}</span>
<span className="text-xs text-muted tabular-nums shrink-0">{count.toLocaleString()}</span>
</Checkbox>
)
}
type CategorySectionProps = { group: (typeof CATEGORY_GROUPS)[number] }
function CategorySection({ group }: CategorySectionProps) {
const items = typesByCategory[group.id]
if (!items?.size) return null
const isExpanded = expanded[group.id] ?? false
const allOn = [...items.keys()].every((k) => filter[k] ?? false)
const anyOn = [...items.keys()].some((k) => filter[k] ?? false)
const q = search.toLowerCase()
const filteredItems = q
? [...items.entries()].filter(([k, v]) => v.label.toLowerCase().includes(q) || k.toLowerCase().includes(q))
: [...items.entries()]
if (q && filteredItems.length === 0) return null
return (
<div className="mb-1">
<div className="flex items-center gap-1 px-2 py-1.5">
<Checkbox
isSelected={allOn}
isIndeterminate={!allOn && anyOn}
onChange={(v) => { [...items.keys()].forEach((k) => onToggle(k, !v)) }}
>
<Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>
</Checkbox>
<button
type="button"
className="flex-1 flex items-center gap-1.5 text-left"
onClick={() => setExpanded((e) => ({ ...e, [group.id]: !e[group.id] }))}
>
<span style={{ color: CAT_COLOR[group.id] }} className="text-xs shrink-0"></span>
<span className="text-xs font-medium text-muted uppercase tracking-wide">{t(group.labelKey as never)}</span>
<span className="text-xs text-muted/60 ml-1">
{[...items.values()].reduce((s, v) => s + v.count, 0).toLocaleString()}
</span>
<Icon
name={isExpanded || q ? 'chevron-down' : 'chevron-right'}
className="size-3 text-muted ml-auto"
/>
</button>
</div>
{(isExpanded || !!q) && (
<div className="ml-1">
{filteredItems.map(([key, { label, count }]) => (
<TypeRow key={key} typeKey={key} label={label} count={count} category={group.id} />
))}
</div>
)}
</div>
)
}
return (
<div className="flex flex-col w-60 shrink-0 min-h-0 overflow-hidden border-r border-border bg-background">
<div className="px-2 pt-2 pb-1 shrink-0">
<SearchField
aria-label={t('liveMap.filter')}
value={search}
onChange={setSearch}
>
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input placeholder={t('liveMap.filterSearch')} />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
</div>
<div className="px-2 pb-1 shrink-0 flex justify-end">
<Button
variant="ghost"
className="text-xs text-muted hover:text-accent px-1 h-auto min-w-0"
onPress={onClear}
>
{t('liveMap.clearFilters')}
</Button>
</div>
<div className="flex-1 overflow-y-auto px-2 pb-2">
{!search && (
<Panel className="mb-2 mt-1">
<SectionLabel>{t('liveMap.filterLive')}</SectionLabel>
{LIVE_TYPES.map((id) => (
<Checkbox
key={id}
isSelected={filter[id] ?? false}
onChange={() => onToggle(id, filter[id] ?? false)}
className="flex items-center gap-2 py-1.5 px-1 hover:bg-surface-secondary rounded-[var(--radius)] w-full max-w-none"
>
<Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>
<span style={{ color: CAT_COLOR[id] }} className="text-xs shrink-0"></span>
<span className="flex-1 text-xs text-foreground">{LIVE_LABELS[id]}</span>
</Checkbox>
))}
</Panel>
)}
{!search && HEATMAP_BOUNDS[mapKey] && (
<Panel className="mb-2">
<SectionLabel>{t('liveMap.filterDensity')}</SectionLabel>
<Checkbox
isSelected={heatmapMode}
onChange={onHeatmapToggle}
className="flex items-center gap-2 py-1.5 px-1 hover:bg-surface-secondary rounded-[var(--radius)] w-full max-w-none"
>
<Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>
<Icon name="layers" className="text-accent shrink-0" />
<span className="flex-1 text-xs text-foreground">{t('liveMap.densityOverlay')}</span>
</Checkbox>
{heatmapMode && (() => {
const active = (HEATMAP_TYPES[mapKey] ?? []).filter((type) => filter[heatmapFilterKey(type)] ?? false)
if (!active.length) return (
<p className="text-xs text-muted px-1 pb-1">{t('liveMap.densityNoneSelected')}</p>
)
return (
<div className="px-1 pb-1 flex flex-col gap-0.5">
{active.map((type) => (
<div key={type} className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm shrink-0 opacity-80" style={{ background: HEATMAP_COLORS[type] ?? '#888' }} />
<span className="text-xs text-muted truncate">{TYPE_LABELS[type] ?? type.replace(/_/g, ' ')}</span>
</div>
))}
</div>
)
})()}
</Panel>
)}
{CATEGORY_GROUPS.map((group) => (
<CategorySection key={group.id} group={group} />
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
import { useEffect } from 'react'
import { useMap } from 'react-leaflet'
import { IMAGE_BOUNDS } from '../constants'
import type { FitBoundsControllerProps } from '../types'
export function FitBoundsController({ fitRef }: FitBoundsControllerProps) {
const map = useMap()
useEffect(() => {
fitRef.current = () => map.fitBounds(IMAGE_BOUNDS, { animate: true })
}, [map, fitRef])
return null
}

View File

@@ -0,0 +1,86 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useMap } from 'react-leaflet'
import { HEATMAP_BOUNDS, HEATMAP_PREFIX, HEATMAP_TYPES } from '../constants'
import { worldToLatLng, heatmapFilterKey, mapUrl } from '../utils'
import type { HeatmapCanvasLayerProps } from '../types'
export function HeatmapCanvasLayer({
mapKey, effCfg, filter,
}: HeatmapCanvasLayerProps) {
const map = useMap()
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const imageCache = useRef(new Map<string, HTMLImageElement | null>())
const pendingRef = useRef(new Set<string>())
const bounds = HEATMAP_BOUNDS[mapKey]
const prefix = HEATMAP_PREFIX[mapKey]
const types = useMemo(() => HEATMAP_TYPES[mapKey] ?? [], [mapKey])
const draw = useCallback(() => {
const canvas = canvasRef.current
if (!canvas || !bounds) return
const mapSize = map.getSize()
canvas.width = mapSize.x
canvas.height = mapSize.y
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, mapSize.x, mapSize.y)
const [tlLat, tlLng] = worldToLatLng(bounds.minX, bounds.maxY, effCfg)
const [brLat, brLng] = worldToLatLng(bounds.maxX, bounds.minY, effCfg)
const tl = map.latLngToContainerPoint([tlLat, tlLng])
const br = map.latLngToContainerPoint([brLat, brLng])
const dw = br.x - tl.x
const dh = br.y - tl.y
ctx.globalAlpha = 0.65
for (const type of types) {
if (!(filter[heatmapFilterKey(type)] ?? false)) continue
const img = imageCache.current.get(type)
if (img) ctx.drawImage(img, tl.x, tl.y, dw, dh)
}
ctx.globalAlpha = 1
}, [map, bounds, effCfg, filter, types])
useEffect(() => {
if (!prefix) return
for (const type of types) {
if (!(filter[heatmapFilterKey(type)] ?? false)) continue
if (imageCache.current.has(type) || pendingRef.current.has(type)) continue
pendingRef.current.add(type)
const img = new Image()
img.onload = () => {
imageCache.current.set(type, img)
pendingRef.current.delete(type)
draw()
}
img.onerror = () => {
imageCache.current.set(type, null)
pendingRef.current.delete(type)
}
img.src = mapUrl(`map-data/${prefix}-heatmap-${type}.png`)
}
}, [filter, types, prefix, draw])
useEffect(() => {
const container = map.getContainer()
const canvas = document.createElement('canvas')
canvas.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;z-index:498'
container.appendChild(canvas)
canvasRef.current = canvas
return () => {
canvas.remove()
canvasRef.current = null
}
}, [map])
useEffect(() => {
map.on('move zoom moveend zoomend viewreset resize', draw)
draw()
return () => {
map.off('move zoom moveend zoomend viewreset resize', draw)
}
}, [map, draw])
return null
}

View File

@@ -0,0 +1,18 @@
import { useEffect } from 'react'
import { useMap } from 'react-leaflet'
import { IMAGE_BOUNDS } from '../constants'
import type { InvalidateOnActiveProps } from '../types'
export function InvalidateOnActive({ active }: InvalidateOnActiveProps) {
const map = useMap()
useEffect(() => {
if (active) {
const id = setTimeout(() => {
map.invalidateSize()
map.fitBounds(IMAGE_BOUNDS)
}, 50)
return () => clearTimeout(id)
}
}, [active, map])
return null
}

View File

@@ -0,0 +1,11 @@
import { useMapEvents } from 'react-leaflet'
import type { MapClickCaptureProps } from '../types'
export function MapClickCapture({ active, onPick }: MapClickCaptureProps) {
useMapEvents({
click(e) {
if (active) onPick(e.latlng.lat, e.latlng.lng)
},
})
return null
}

View File

@@ -0,0 +1,38 @@
import { useEffect } from 'react'
import { useMap } from 'react-leaflet'
import L from 'leaflet'
import { TILE_CDN } from '../constants'
import type { MapTileLayerProps } from '../types'
export function MapTileLayer({ tileId }: MapTileLayerProps) {
const map = useMap()
useEffect(() => {
const layer = new L.TileLayer('', {
tileSize: 512,
minZoom: -3,
maxZoom: 4,
maxNativeZoom: 1,
noWrap: true,
attribution: '',
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(layer as any).getTileUrl = (coords: L.Coords): string => {
const cdnZ = Math.min(4, Math.max(0, coords.z + 3))
const scale = Math.pow(2, coords.z + 3 - cdnZ)
const cdnX = Math.floor(coords.x / scale)
const cdnY = Math.floor(Math.pow(2, cdnZ) + coords.y / scale)
const maxTile = Math.pow(2, cdnZ)
if (cdnX < 0 || cdnX >= maxTile || cdnY < 0 || cdnY >= maxTile) return ''
return `${TILE_CDN}/${tileId}/${cdnZ}/${cdnY}/${cdnX}.webp`
}
layer.addTo(map)
return () => {
layer.remove()
}
}, [map, tileId])
return null
}

View File

@@ -0,0 +1,99 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useMap } from 'react-leaflet'
import { ICON_POS, CAT_COLOR, SPRITE_CELL, SPRITE_URL } from '../constants'
import { worldToLatLng, filterKey } from '../utils'
import type { SpawnCanvasLayerProps } from '../types'
export function SpawnCanvasLayer({
spawns, effCfg, filter, heatmapMode,
}: SpawnCanvasLayerProps) {
const map = useMap()
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const spriteRef = useRef<HTMLImageElement | null>(null)
const spriteReady = useRef(false)
const visible = useMemo(
() => spawns.filter((s) => {
if (!(filter[filterKey(s.type)] ?? false)) return false
if (heatmapMode && (s.category === 'resources' || s.category === 'hazards')) return false
return true
}),
[spawns, filter, heatmapMode],
)
const draw = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
const mapSize = map.getSize()
canvas.width = mapSize.x
canvas.height = mapSize.y
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, mapSize.x, mapSize.y)
const sprite = spriteRef.current
for (const s of visible) {
const isDense = s.category === 'resources' || s.category === 'static'
const [lat, lng] = worldToLatLng(s.x, s.y, effCfg)
const pt = map.latLngToContainerPoint([lat, lng])
if (pt.x < -32 || pt.x > mapSize.x + 32 || pt.y < -32 || pt.y > mapSize.y + 32) continue
const typeKey = filterKey(s.type)
const pos = ICON_POS[typeKey]
const iconSize = isDense ? 20 : 28
if (sprite && spriteReady.current && pos) {
const [col, row] = pos
ctx.drawImage(
sprite,
col * SPRITE_CELL, row * SPRITE_CELL,
SPRITE_CELL, SPRITE_CELL,
pt.x - iconSize / 2, pt.y - iconSize / 2,
iconSize, iconSize,
)
}
else {
ctx.beginPath()
ctx.arc(pt.x, pt.y, isDense ? 3 : 5, 0, Math.PI * 2)
ctx.fillStyle = CAT_COLOR[s.category] ?? '#888'
ctx.globalAlpha = 0.65
ctx.fill()
ctx.globalAlpha = 1
}
}
}, [map, visible, effCfg])
useEffect(() => {
const container = map.getContainer()
const canvas = document.createElement('canvas')
canvas.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;z-index:499'
container.appendChild(canvas)
canvasRef.current = canvas
const img = new Image()
img.src = SPRITE_URL
img.onload = () => {
spriteRef.current = img
spriteReady.current = true
draw()
}
return () => {
canvas.remove()
canvasRef.current = null
}
}, [map]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
map.on('move zoom moveend zoomend viewreset resize', draw)
draw()
return () => {
map.off('move zoom moveend zoomend viewreset resize', draw)
}
}, [map, draw])
return null
}

View File

@@ -0,0 +1,27 @@
import { ICON_POS, SPRITE_URL, SPRITE_COLS, SPRITE_ROWS, SPRITE_CELL } from '../constants'
import type { SpriteIconProps } from '../types'
export function SpriteIcon({ type, size = 22 }: SpriteIconProps) {
const pos = ICON_POS[type]
if (!pos) return null
const [col, row] = pos
const scale = size / SPRITE_CELL
const bw = SPRITE_COLS * SPRITE_CELL * scale
const bh = SPRITE_ROWS * SPRITE_CELL * scale
const bx = -(col * SPRITE_CELL * scale)
const by = -(row * SPRITE_CELL * scale)
return (
<span
className="inline-block shrink-0"
style={{
width: size,
height: size,
backgroundImage: `url(${SPRITE_URL})`,
backgroundPosition: `${bx}px ${by}px`,
backgroundSize: `${bw}px ${bh}px`,
backgroundRepeat: 'no-repeat',
imageRendering: 'pixelated',
}}
/>
)
}

View File

@@ -0,0 +1,89 @@
import { useCallback, useEffect, useRef } from 'react'
import { useMap } from 'react-leaflet'
import { DD_COLS, DD_ROWS } from '../constants'
import { worldToLatLng } from '../utils'
import type { ZoneGridLayerProps } from '../types'
export function ZoneGridLayer({ effCfg }: ZoneGridLayerProps) {
const map = useMap()
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const draw = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
const mapSize = map.getSize()
canvas.width = mapSize.x
canvas.height = mapSize.y
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, mapSize.x, mapSize.y)
const b = { minX: effCfg.minX, maxX: effCfg.maxX, minY: effCfg.minY, maxY: effCfg.maxY }
const cellW = (b.maxX - b.minX) / 9
const cellH = (b.maxY - b.minY) / 9
ctx.strokeStyle = 'rgba(255,255,255,0.25)'
ctx.lineWidth = 1
ctx.fillStyle = 'rgba(255,255,255,0.45)'
ctx.font = '11px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
for (let ci = 0; ci <= 9; ci++) {
const x = b.minX + ci * cellW
const [latB, lngB] = worldToLatLng(x, b.minY, effCfg)
const [latT, lngT] = worldToLatLng(x, b.maxY, effCfg)
const ptB = map.latLngToContainerPoint([latB, lngB])
const ptT = map.latLngToContainerPoint([latT, lngT])
ctx.beginPath()
ctx.moveTo(ptB.x, ptB.y)
ctx.lineTo(ptT.x, ptT.y)
ctx.stroke()
}
for (let ri = 0; ri <= 9; ri++) {
const y = b.minY + ri * cellH
const [latL, lngL] = worldToLatLng(b.minX, y, effCfg)
const [latR, lngR] = worldToLatLng(b.maxX, y, effCfg)
const ptL = map.latLngToContainerPoint([latL, lngL])
const ptR = map.latLngToContainerPoint([latR, lngR])
ctx.beginPath()
ctx.moveTo(ptL.x, ptL.y)
ctx.lineTo(ptR.x, ptR.y)
ctx.stroke()
}
for (let ci = 0; ci < 9; ci++) {
for (let ri = 0; ri < 9; ri++) {
const cx = b.minX + (ci + 0.5) * cellW
const cy = b.minY + (ri + 0.5) * cellH
const [lat, lng] = worldToLatLng(cx, cy, effCfg)
const pt = map.latLngToContainerPoint([lat, lng])
if (pt.x < -20 || pt.x > mapSize.x + 20 || pt.y < -20 || pt.y > mapSize.y + 20) continue
const label = `${DD_ROWS[ri]}${DD_COLS[ci]}`
ctx.fillText(label, pt.x, pt.y)
}
}
}, [map, effCfg])
useEffect(() => {
const container = map.getContainer()
const canvas = document.createElement('canvas')
canvas.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;z-index:497'
container.appendChild(canvas)
canvasRef.current = canvas
return () => {
canvas.remove()
canvasRef.current = null
}
}, [map])
useEffect(() => {
map.on('move zoom moveend zoomend viewreset resize', draw)
draw()
return () => {
map.off('move zoom moveend zoomend viewreset resize', draw)
}
}, [map, draw])
return null
}

View File

@@ -0,0 +1,308 @@
import type { LatLngBoundsExpression } from 'leaflet'
import type { MapCfg } from './types'
import { mapUrl } from './utils'
const MAP_BASE = ((import.meta.env.VITE_CDN_BASE_URL as string) ?? 'https://assets.dune.layout.tools').replace(/\/$/, '')
const TILE_CDN = 'https://cdn.th.gl/dune-awakening/map-tiles'
const IMG_W = 4096
const IMG_H = 4096
const IMAGE_BOUNDS: LatLngBoundsExpression = [[0, 0], [IMG_H, IMG_W]]
const POLL_MS = 30000
const SPRITE_URL = mapUrl('map-data/map-icons.webp')
const SPRITE_COLS = 11
const SPRITE_ROWS = 12
const SPRITE_CELL = 64
const ICON_POS: Record<string, [number, number]> = {
basic: [3, 0], vbasic: [3, 0], wbasic: [3, 0], ebasic: [3, 0], rbasic: [3, 0], srbasic: [3, 0],
rare: [1, 0], vrare: [1, 0], wrare: [1, 0], drare: [1, 0],
ultra_rare: [1, 1],
small_ultra_rare: [6, 0],
ammo: [2, 1], vammo: [2, 1], wammo: [2, 1], uammo: [2, 1], dammo: [2, 1],
medical: [3, 1],
weapon: [9, 0],
corpse: [2, 0], vcorpse: [2, 0], fcorpse: [2, 0],
fuel: [1, 2], vfuel: [1, 2], wfuel: [1, 2], dfuel: [1, 2], ufuel: [1, 2], owfuel: [1, 2],
contract: [8, 0],
refinery: [4, 3],
water_tank: [1, 3],
buried_treasure: [4, 9],
treasure_loot_container: [3, 0],
cave: [0, 0],
intel_point: [4, 1],
enemy_camp: [4, 0],
primitive: [5, 0], kirab_camp: [5, 0],
shipwreck: [7, 0],
trading_post: [9, 1],
taxi: [4, 6],
bank: [10, 6],
discoverable: [6, 7],
exploration: [9, 2],
buggy: [2, 3], ebuggy: [2, 3],
bike: [10, 2],
bene_gesserit_trainer: [1, 4],
mentat: [9, 4],
planetologist: [8, 2],
swordmaster: [1, 5],
trooper: [10, 1],
blue_id_band: [6, 2],
green_id_band: [0, 1],
orange_id_band: [5, 2],
purple_id_band: [10, 0],
red_id_band: [5, 1],
spice_field_small: [10, 7],
spice_field_medium: [0, 8],
spice_field_large: [1, 8],
agave_seeds: [1, 9],
azurite: [8, 8], azurite_pickup: [8, 8],
basalt: [5, 8], basalt_pickup: [5, 8],
bauxite: [7, 8], bauxite_pickup: [7, 8],
dolomite: [10, 8], dolomite_pickup: [10, 8],
erythrite: [0, 9], erythrite_pickup: [0, 9],
fiber_plant: [6, 8], plant_fiber: [6, 8],
fuel_cells: [5, 9],
jasmium: [3, 9], jasmium_crystal: [3, 9],
magnetite: [2, 9], magnetite_pickup: [2, 9],
primrose_field: [9, 7],
rhyolite: [9, 8], rhyolite_pickup: [9, 8],
scrap_electronics: [7, 9],
scrap_metal: [6, 9],
stravidium: [4, 8],
titanium_ore: [3, 8],
barkeep: [0, 7],
base_vendor: [6, 6],
landsraad_vendor: [2, 7],
scrap_trader: [9, 6],
spice_merchant: [7, 6],
vehicle_vendor: [5, 6],
water_seller: [8, 6],
weapons_merchant: [1, 7],
banker: [10, 6],
atreides_npc: [3, 4],
harkonnen_npc: [8, 4],
fremen_npc: [0, 6],
bene_gesserit_npc: [10, 5],
choam_npc: [7, 5],
bandits_npc: [3, 5],
sardaukar_npc: [9, 5],
smugglers_npc: [6, 5],
spacing_guild_npc: [8, 5],
unaffiliated_npc: [7, 1],
alexin: [1, 6], argosaz: [0, 2], dyvetz: [7, 4], ecaz: [10, 4],
hagal: [4, 4], hurata: [9, 3], imota: [5, 5], kenola: [3, 3],
lindaren: [5, 4], maros: [8, 7], mikarrol: [4, 5], moritani: [6, 4],
mutelli: [4, 7], novebruns: [8, 3], richese: [2, 4], sor: [2, 5],
spinette: [2, 6], taligari: [0, 3], thorvald: [0, 5], tseida: [7, 3],
varota: [3, 7], vernius: [0, 4], wallach: [5, 7], wayku: [7, 7], wydras: [8, 1],
aluminum_ore: [7, 8],
copper_ore: [8, 8],
carbon_fiber: [10, 8],
iron_ore: [2, 9],
stone: [9, 8],
fiber: [6, 8],
cistanche: [1, 9],
saguaro_cactus: [1, 9],
t6_resource_a: [4, 8],
t6_resource_b: [3, 8],
sandworm_territory: [4, 0],
enemycamp: [4, 0], enemyoutpost: [6, 1], enemylaboroutpost: [10, 3],
wreck: [2, 2], tradingpost: [9, 1], sietch: [0, 2], ecolab: [7, 2],
small_shipwreck: [7, 0], atreides: [3, 4], harkonnen: [8, 4], poi: [6, 7],
npc_harkonnen: [8, 5], npc_atreides: [3, 5], npc_bandits: [3, 5],
npc_unaffiliated: [7, 1], npc_choam: [7, 5], npc_fremen: [0, 6],
npc_sardaukar: [9, 5], npc_smugglers: [6, 5], npc_spacingguild: [8, 5],
trainersswordmaster: [1, 5], trainersmentat: [9, 4], trainersbenegesserit: [1, 4],
trainersplanetologist: [8, 2], trainerstrooper: [10, 1],
sandbike: [10, 2],
}
const CAT_COLOR: Record<string, string> = {
player: '#3b9dff', vehicle: '#5fd35a', base: '#e0a13a',
resources: '#f5a623', locations: '#9b59b6', npcs: '#e74c3c',
vendors: '#2ecc71', landsraad: '#e91e8c', static: '#7f8c8d',
hazards: '#ff5020',
}
const TYPE_LABELS: Record<string, string> = {
basic: 'Basic', vbasic: 'Basic', wbasic: 'Basic', ebasic: 'Basic', rbasic: 'Basic', srbasic: 'Basic',
rare: 'Rare', vrare: 'Rare', wrare: 'Rare', drare: 'Rare',
ultra_rare: 'Ultra Rare', ammo: 'Ammo', vammo: 'Ammo', wammo: 'Ammo', uammo: 'Ammo', dammo: 'Ammo',
medical: 'Medical', weapon: 'Weapon', corpse: 'Corpse', vcorpse: 'Corpse', fcorpse: 'Corpse',
fuel: 'Fuel', vfuel: 'Fuel', wfuel: 'Fuel', dfuel: 'Fuel', ufuel: 'Fuel', owfuel: 'Fuel',
contract: 'Contract', refinery: 'Refinery', water_tank: 'Water Tank',
treasure_loot_container: 'Loot Container',
enemy_camp: 'Enemy Camp', primitive: 'Primitive Camp', kirab_camp: 'Kirab Camp',
intel_point: 'Intel Point', buggy: 'Buggy', ebuggy: 'Buggy',
spice_field_small: 'Small Spice', spice_field_medium: 'Medium Spice', spice_field_large: 'Large Spice',
basalt: 'Basalt Stone', basalt_pickup: 'Basalt (Node)',
fiber_plant: 'Plant Fiber', plant_fiber: 'Plant Fiber',
bauxite: 'Aluminum Ore', bauxite_pickup: 'Aluminum (Node)',
agave_seeds: 'Agave Seeds',
erythrite: 'Erythrite Crystal', erythrite_pickup: 'Erythrite (Node)',
jasmium: 'Jasmium Crystal', jasmium_crystal: 'Jasmium Crystal',
scrap_electronics: 'Scrap Electronics', scrap_metal: 'Scrap Metal',
fuel_cells: 'Fuel Cells',
azurite: 'Copper Ore', azurite_pickup: 'Copper (Node)',
dolomite: 'Carbon Ore', dolomite_pickup: 'Carbon (Node)',
magnetite: 'Iron Ore', magnetite_pickup: 'Iron (Node)',
rhyolite: 'Granite Stone', rhyolite_pickup: 'Granite (Node)',
primrose_field: 'Primrose Field', stravidium: 'Stravidium', titanium_ore: 'Titanium',
aluminum_ore: 'Aluminum Ore', copper_ore: 'Copper Ore', carbon_fiber: 'Carbon Fiber',
iron_ore: 'Iron Ore', stone: 'Stone', fiber: 'Plant Fiber',
cistanche: 'Cistanche', saguaro_cactus: 'Saguaro Cactus',
t6_resource_a: 'T6 Resource A', t6_resource_b: 'T6 Resource B',
sandworm_territory: 'Sandworm Territory', buried_treasure: 'Buried Treasure',
static: 'Static Object',
enemycamp: 'Enemy Camp', enemyoutpost: 'Enemy Outpost', enemylaboroutpost: 'Enemy Lab Outpost',
cave: 'Cave', wreck: 'Wreck', tradingpost: 'Trading Post', sietch: 'Sietch',
ecolab: 'Eco Lab', secret_door: 'Secret Door', shipwreck: 'Shipwreck',
small_shipwreck: 'Small Shipwreck', atreides: 'Atreides', harkonnen: 'Harkonnen', poi: 'Point of Interest',
npc_harkonnen: 'Harkonnen NPC', npc_atreides: 'Atreides NPC', npc_bandits: 'Bandits',
npc_unaffiliated: 'Unaffiliated', npc_choam: 'CHOAM', npc_fremen: 'Fremen',
npc_sardaukar: 'Sardaukar', npc_smugglers: 'Smugglers', npc_spacingguild: 'Spacing Guild',
trainersswordmaster: 'Swordmaster', trainersmentat: 'Mentat', trainersbenegesserit: 'Bene Gesserit',
trainersplanetologist: 'Planetologist', trainerstrooper: 'Trooper',
purple_id_band: 'Purple ID Band', green_id_band: 'Green ID Band',
red_id_band: 'Red ID Band', orange_id_band: 'Orange ID Band', blue_id_band: 'Blue ID Band',
sandbike: 'Sandbike',
}
const TYPE_MERGE_KEY: Record<string, string> = {
vbasic: 'basic', wbasic: 'basic', ebasic: 'basic', rbasic: 'basic', srbasic: 'basic',
vrare: 'rare', wrare: 'rare', drare: 'rare',
vammo: 'ammo', wammo: 'ammo', uammo: 'ammo', dammo: 'ammo',
vcorpse: 'corpse', fcorpse: 'corpse',
vfuel: 'fuel', wfuel: 'fuel', dfuel: 'fuel', ufuel: 'fuel', owfuel: 'fuel',
ebuggy: 'buggy',
basalt_pickup: 'basalt',
bauxite_pickup: 'bauxite',
erythrite_pickup: 'erythrite',
jasmium_crystal: 'jasmium',
azurite_pickup: 'azurite',
dolomite_pickup: 'dolomite',
magnetite_pickup: 'magnetite',
rhyolite_pickup: 'rhyolite',
plant_fiber: 'fiber_plant',
}
const MAPS: MapCfg[] = [
{
key: 'HaggaBasin', label: 'Hagga Basin', image: 'hagga-basin.webp', spawnFile: 'hagga',
tileId: 'survival_1-0c70ddebb3e41cf49915b22e103e94ed',
depthFile: 'hagga-depth.webp',
hasLiveData: true,
minX: -437871, maxX: 350539, minY: -462011, maxY: 376267, flipY: true,
},
{
key: 'DeepDesert', label: 'Deep Desert', image: 'deepdesert.webp', spawnFile: 'deepdesert',
tileId: 'deepdesert_1-40f176fc4cce018dff08f3cd66b52f08',
depthFile: 'deepdesert-depth.webp',
hasLiveData: true,
minX: -1300000, maxX: 1200000, minY: -1300000, maxY: 1200000,
},
{
key: 'Arrakeen', label: 'Arrakeen', image: 'arrakeen.webp', spawnFile: 'arrakeen',
hasLiveData: false,
minX: -32000, maxX: 17000, minY: -10000, maxY: 9500, flipY: true,
},
{
key: 'HarkoVillage', label: 'Harko Village', image: 'harko.webp', spawnFile: 'harko',
hasLiveData: false,
minX: -5000, maxX: 14500, minY: -5500, maxY: 32000,
},
]
const CALIB_LS_KEY = 'dune_admin_livemap_calib'
const HEATMAP_BOUNDS: Record<string, { minX: number, maxX: number, minY: number, maxY: number }> = {
HaggaBasin: { minX: -457200, maxX: 355600, minY: -457200, maxY: 355600 },
DeepDesert: { minX: -1270000, maxX: 1168400, minY: -1270000, maxY: 1168400 },
}
const HEATMAP_PREFIX: Record<string, string> = {
HaggaBasin: 'hagga',
DeepDesert: 'deepdesert',
}
const HEATMAP_TO_FILTER: Record<string, string> = {
aluminum_ore: 'bauxite',
copper_ore: 'azurite',
carbon_fiber: 'dolomite',
iron_ore: 'magnetite',
stone: 'rhyolite',
fiber: 'fiber_plant',
}
const HEATMAP_COLORS: Record<string, string> = {
aluminum_ore: 'rgb(201,130,10)', copper_ore: 'rgb(184,115,51)',
carbon_fiber: 'rgb(90,90,90)', iron_ore: 'rgb(130,130,145)',
stone: 'rgb(160,145,120)', basalt: 'rgb(150,100,50)',
scrap_metal: 'rgb(100,120,145)', fuel: 'rgb(255,200,50)',
fiber: 'rgb(120,200,80)', cistanche: 'rgb(60,180,120)',
saguaro_cactus: 'rgb(40,160,80)', primrose_field: 'rgb(200,200,60)',
jasmium: 'rgb(180,100,220)', erythrite: 'rgb(220,60,60)',
t6_resource_a: 'rgb(100,220,220)', t6_resource_b: 'rgb(60,180,220)',
sandworm_territory: 'rgb(255,80,30)',
}
const DD_ROWS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']
const DD_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9]
const HEATMAP_TYPES: Record<string, string[]> = {
HaggaBasin: [
'aluminum_ore', 'basalt', 'carbon_fiber', 'cistanche', 'copper_ore',
'erythrite', 'fiber', 'fuel', 'iron_ore', 'jasmium',
'primrose_field', 'saguaro_cactus', 'sandworm_territory', 'scrap_metal', 'stone',
],
DeepDesert: [
'aluminum_ore', 'basalt', 'carbon_fiber', 'copper_ore',
'fiber', 'fuel', 'iron_ore',
'sandworm_territory', 'scrap_metal', 'stone', 't6_resource_a', 't6_resource_b',
],
}
const LIVE_TYPES = ['players', 'vehicles', 'bases'] as const
const CATEGORY_GROUPS: { id: string, labelKey: string }[] = [
{ id: 'locations', labelKey: 'liveMap.filterLocations' },
{ id: 'resources', labelKey: 'liveMap.filterResources' },
{ id: 'npcs', labelKey: 'liveMap.filterNPCs' },
{ id: 'vendors', labelKey: 'liveMap.filterVendors' },
{ id: 'trainers', labelKey: 'liveMap.filterTrainers' },
{ id: 'landsraad', labelKey: 'liveMap.filterLandsraad' },
{ id: 'pentashield_keys', labelKey: 'liveMap.filterKeys' },
{ id: 'vehicles', labelKey: 'liveMap.vehicles' },
{ id: 'static', labelKey: 'liveMap.filterStaticObjects' },
{ id: 'hazards', labelKey: 'liveMap.filterHazards' },
]
export {
MAP_BASE,
TILE_CDN,
IMG_W,
IMG_H,
IMAGE_BOUNDS,
POLL_MS,
SPRITE_URL,
SPRITE_COLS,
SPRITE_ROWS,
SPRITE_CELL,
ICON_POS,
CAT_COLOR,
TYPE_LABELS,
TYPE_MERGE_KEY,
MAPS,
CALIB_LS_KEY,
HEATMAP_BOUNDS,
HEATMAP_PREFIX,
HEATMAP_TO_FILTER,
HEATMAP_COLORS,
DD_ROWS,
DD_COLS,
HEATMAP_TYPES,
LIVE_TYPES,
CATEGORY_GROUPS,
}

View File

@@ -0,0 +1,606 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import type React from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Select, ListBox, Spinner, toast } from '@heroui/react'
import { MapContainer, ImageOverlay, CircleMarker, Marker, Tooltip } from 'react-leaflet'
import L from 'leaflet'
import { CRS } from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { api, ApiError } from '../../api/client'
import type { MapMarker, Player } from '../../api/client'
import { ConfirmDialog, Icon, PageHeader } from '../../dune-ui'
import { useAutoRefresh } from '../../hooks/useAutoRefresh'
import { InvalidateOnActive } from './components/InvalidateOnActive'
import { MapClickCapture } from './components/MapClickCapture'
import { SpawnCanvasLayer } from './components/SpawnCanvasLayer'
import { HeatmapCanvasLayer } from './components/HeatmapCanvasLayer'
import { MapTileLayer } from './components/MapTileLayer'
import { ZoneGridLayer } from './components/ZoneGridLayer'
import { FitBoundsController } from './components/FitBoundsController'
import { FilterPanel } from './components/FilterPanel'
import {
MAPS, CAT_COLOR, IMAGE_BOUNDS, POLL_MS, IMG_H, IMG_W,
} from './constants'
import {
worldToLatLng, latLngToWorld, solveBounds, loadCalib, loadFilter, saveFilter, mapUrl,
} from './utils'
import type { LiveMapTabProps, SpawnEntry, SpawnFile, CalibPoint, MapCfg, Bounds } from './types'
export const LiveMapTab: React.FC<LiveMapTabProps> = ({ isActive = true }) => {
const { t } = useTranslation()
const [mapKey, setMapKey] = useState<string>('HaggaBasin')
const [markers, setMarkers] = useState<MapMarker[]>([])
const [loading, setLoading] = useState(false)
const [unsupported, setUnsupported] = useState(false)
const [updatedLabel, setUpdatedLabel] = useState<string>('')
const [calibrating, setCalibrating] = useState(false)
const [calibPoints, setCalibPoints] = useState<CalibPoint[]>([])
const [calibOverride, setCalibOverride] = useState<Record<string, Bounds>>(() => loadCalib())
const [spawns, setSpawns] = useState<SpawnEntry[]>([])
const loadedSpawnKey = useRef<string>('')
const isDragging = useRef(false)
const [filter, setFilter] = useState<Record<string, boolean>>(loadFilter)
const [selectedFlsId, setSelectedFlsId] = useState<string>('')
const [dragConfirm, setDragConfirm] = useState<{
flsId: string
name: string
x: number
y: number
} | null>(null)
const [heatmapMode, setHeatmapMode] = useState(false)
const fitBoundsRef = useRef<(() => void) | null>(null)
const [teleportMode, setTeleportMode] = useState(false)
const [teleportDest, setTeleportDest] = useState<{ x: number, y: number } | null>(null)
const [teleportFlsId, setTeleportFlsId] = useState<string>('')
const [allPlayers, setAllPlayers] = useState<Player[]>([])
const [teleporting, setTeleporting] = useState(false)
const baseCfg = MAPS.find((m) => m.key === mapKey) ?? MAPS[0]
const effCfg: MapCfg = useMemo(
() => ({ ...baseCfg, ...(calibOverride[mapKey] ?? {}) }),
[baseCfg, calibOverride, mapKey],
)
const load = useCallback((key: string) => {
if (isDragging.current) return
const cfg = MAPS.find((m) => m.key === key)
if (!cfg?.hasLiveData) {
setMarkers([])
setUnsupported(false)
setUpdatedLabel(new Date().toLocaleTimeString())
return
}
Promise.resolve()
.then(() => {
if (isDragging.current) return
setLoading(true)
setUnsupported(false)
})
.then(() => api.map.markers(key))
.then((rows) => {
if (isDragging.current) return
setMarkers(rows)
setUpdatedLabel(new Date().toLocaleTimeString())
})
.catch((e: unknown) => {
if (isDragging.current) return
if (e instanceof ApiError && e.status === 404) setUnsupported(true)
else toast.danger(t('liveMap.failedToLoad', { message: e instanceof Error ? e.message : String(e) }))
setMarkers([])
})
.finally(() => { if (!isDragging.current) setLoading(false) })
}, [t])
const loadCurrent = useCallback(() => load(mapKey), [load, mapKey])
useEffect(() => {
if (isActive) {
const id = setTimeout(loadCurrent, 0)
return () => clearTimeout(id)
}
}, [isActive, loadCurrent])
const { countdown, refresh } = useAutoRefresh(loadCurrent, POLL_MS, isActive)
useEffect(() => {
const cfg = MAPS.find((m) => m.key === mapKey)
if (!cfg?.spawnFile || loadedSpawnKey.current === mapKey) return
loadedSpawnKey.current = mapKey
fetch(mapUrl(`map-data/${cfg.spawnFile}-spawns.json`))
.then((r) => r.json() as Promise<SpawnFile>)
.then((d) => setSpawns(d.spawns))
.catch(() => setSpawns([]))
}, [mapKey])
useEffect(() => {
if (teleportMode && allPlayers.length === 0) {
api.players.list().then(setAllPlayers).catch(() => {})
}
}, [teleportMode, allPlayers.length])
const playerCount = markers.filter((m) => m.type === 'player').length
const vehicleCount = markers.filter((m) => m.type === 'vehicle').length
const baseCount = markers.filter((m) => m.type === 'base').length
const orderedLive = useMemo(
() => [...markers]
.sort((a, b) => (a.type === 'player' ? 1 : 0) - (b.type === 'player' ? 1 : 0))
.map((m) => {
const isPlayer = m.type === 'player'
const isBase = m.type === 'base'
const size = isPlayer ? 32 : isBase ? 28 : 24
const baseColor = CAT_COLOR[m.type] ?? CAT_COLOR.base
const label = isPlayer ? (m.name?.[0]?.toUpperCase() ?? '?') : isBase ? '🏠' : '🚗'
const cursor = isPlayer ? 'grab' : 'default'
const makeHtml = (color: string) =>
`<div style="width:${size}px;height:${size}px;border-radius:50%;background:${color};border:2.5px solid #0b0b0b;box-shadow:0 0 0 1.5px ${color}40;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;color:#0b0b0b;line-height:1;cursor:${cursor}">${label}</div>`
const iconOpts = { iconSize: [size, size] as L.PointTuple, iconAnchor: [size / 2, size / 2] as L.PointTuple, className: '' }
return {
...m,
center: worldToLatLng(m.x, m.y, effCfg) as L.LatLngTuple,
isPlayer,
isBase,
size,
icon: L.divIcon({ ...iconOpts, html: makeHtml(baseColor) }),
selectedIcon: L.divIcon({ ...iconOpts, html: makeHtml('#f59e0b') }),
}
}),
[markers, effCfg],
)
const handleMapClick = useCallback((lat: number, lng: number) => {
if (calibrating) {
const player = markers.find((m) => m.type === 'player')
if (!player) {
toast.danger(t('liveMap.calibNoPlayer'))
return
}
setCalibPoints((prev) => {
const next = [...prev, { wx: player.x, wy: player.y, fracX: lng / IMG_W, fracYup: lat / IMG_H }]
const solved = solveBounds(next)
if (solved) {
setCalibOverride((c) => {
const merged = { ...c, [mapKey]: solved }
try {
localStorage.setItem('dune_admin_livemap_calib', JSON.stringify(merged))
}
catch { /* quota */ }
return merged
})
}
return next
})
return
}
if (teleportMode) {
const { x, y } = latLngToWorld(lat, lng, effCfg)
setTeleportDest({ x: Math.round(x), y: Math.round(y) })
}
}, [calibrating, teleportMode, markers, mapKey, effCfg, t])
const clearCalib = useCallback(() => {
setCalibPoints([])
setCalibOverride((c) => {
const merged = { ...c }
delete merged[mapKey]
try {
localStorage.setItem('dune_admin_livemap_calib', JSON.stringify(merged))
}
catch { /* quota */ }
return merged
})
}, [mapKey])
const solvedStr = useMemo(() => {
const b = calibOverride[mapKey]
return b
? `minX: ${Math.round(b.minX)}, maxX: ${Math.round(b.maxX)}, minY: ${Math.round(b.minY)}, maxY: ${Math.round(b.maxY)}, flipY: ${!!b.flipY}`
: ''
}, [calibOverride, mapKey])
const doTeleport = useCallback(async () => {
if (!teleportDest || !teleportFlsId) return
setTeleporting(true)
try {
await api.players.teleportCoords(teleportFlsId, teleportDest.x, teleportDest.y, 5000)
toast.success(t('liveMap.teleportSent'))
setTeleportDest(null)
}
catch (e) {
toast.danger(e instanceof Error ? e.message : String(e))
}
finally {
setTeleporting(false)
}
}, [teleportDest, teleportFlsId, t])
const toggleFilter = useCallback((key: string, currentVisual: boolean) => {
setFilter((f) => {
const next = { ...f, [key]: !currentVisual }
saveFilter(next)
return next
})
}, [])
const clearFilters = useCallback(() => {
setFilter((f) => {
const next: Record<string, boolean> = {}
Object.keys(f).forEach((k) => {
next[k] = false
})
Object.assign(next, { players: true, vehicles: true, bases: true })
saveFilter(next)
return next
})
}, [])
const mapCursor = calibrating || teleportMode ? 'crosshair' : 'grab'
const currentMap = MAPS.find((m) => m.key === mapKey) ?? MAPS[0]
return (
<div className="flex flex-col h-full gap-3 min-h-0">
<PageHeader title={t('liveMap.title')} subtitle={t('liveMap.subtitle')}>
<Button size="sm" variant="ghost" onPress={refresh} isDisabled={loading}>
{loading
? <Spinner size="sm" color="current" />
: (
<>
{isActive && currentMap.hasLiveData && (
<span className="w-7 text-right tabular-nums text-muted/60 text-xs">
{countdown}
s
</span>
)}
<Icon name="refresh-cw" />
</>
)}
</Button>
</PageHeader>
<div className="shrink-0 flex items-start gap-2 rounded-[var(--radius)] border border-border bg-surface px-3 py-2 text-xs">
<Icon name="flask-conical" className="size-4 shrink-0 mt-0.5 text-accent" />
<div>
<span className="font-medium text-accent">{t('liveMap.betaTitle')}</span>
{' '}
<span className="text-muted">{t('liveMap.betaBody')}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Select
aria-label={t('liveMap.title')}
selectedKey={mapKey}
onSelectionChange={(k) => {
const key = String(k)
loadedSpawnKey.current = ''
setMapKey(key)
setSpawns([])
setTeleportDest(null)
setCalibrating(false)
}}
className="w-44"
>
<Select.Trigger>
<Icon name="map" className="size-3.5 text-muted shrink-0 mr-1" />
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
{MAPS.map((m) => (
<ListBox.Item key={m.key} id={m.key} textValue={m.label}>
{m.label}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
<div className="h-4 border-l border-border mx-0.5" />
<Button size="sm" variant="outline" onPress={() => fitBoundsRef.current?.()}>
<Icon name="home" />
</Button>
<Button
size="sm"
variant={teleportMode ? 'primary' : 'outline'}
onPress={() => {
setTeleportMode((v) => !v)
setTeleportDest(null)
}}
>
<Icon name="navigation" />
{' '}
{t('liveMap.teleportMode')}
</Button>
<Button size="sm" variant={calibrating ? 'primary' : 'outline'} onPress={() => setCalibrating((v) => !v)}>
<Icon name="crosshair" />
{' '}
{t('liveMap.calibrate')}
</Button>
{calibrating && (
<Button size="sm" variant="outline" onPress={clearCalib}>{t('liveMap.clear')}</Button>
)}
</div>
<div className="flex flex-wrap gap-4 shrink-0 text-xs text-muted">
{currentMap.hasLiveData && (
<>
<span>
<span style={{ color: CAT_COLOR.player }}></span>
{' '}
{t('liveMap.players')}
{': '}
{playerCount}
</span>
<span>
<span style={{ color: CAT_COLOR.vehicle }}></span>
{' '}
{t('liveMap.vehicles')}
{': '}
{vehicleCount}
</span>
<span>
<span style={{ color: CAT_COLOR.base }}></span>
{' '}
{t('liveMap.filterBases')}
{': '}
{baseCount}
</span>
<span>
{t('liveMap.total')}
{': '}
{markers.length}
</span>
</>
)}
{spawns.length > 0 && <span>{t('liveMap.spawnsLoaded', { count: spawns.length })}</span>}
{updatedLabel !== '' && <span className="ml-auto">{t('liveMap.updated', { time: updatedLabel })}</span>}
</div>
{teleportMode && (
<div className="shrink-0 rounded-[var(--radius)] border border-accent/40 bg-surface px-3 py-2 text-xs flex flex-wrap items-center gap-3">
<div className="text-accent font-medium">
<Icon name="navigation" className="size-3 inline mr-1" />
{teleportDest
? t('liveMap.spawnTooltipCoords', { x: teleportDest.x, y: teleportDest.y })
: t('liveMap.teleportModeActive')}
</div>
{teleportDest && (
<>
<Select
aria-label={t('liveMap.teleportPlayer')}
placeholder={t('liveMap.teleportSelectPlayer')}
selectedKey={teleportFlsId || null}
onSelectionChange={(k) => setTeleportFlsId(k ? String(k) : '')}
className="w-56"
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
{allPlayers.map((p) => (
<ListBox.Item key={p.fls_id} id={p.fls_id} textValue={p.name}>
{p.name}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
<Button size="sm" isDisabled={!teleportFlsId || teleporting} onPress={doTeleport}>
{teleporting ? <Spinner size="sm" color="current" /> : t('liveMap.teleportHere')}
</Button>
<Button size="sm" variant="ghost" onPress={() => setTeleportDest(null)}></Button>
</>
)}
</div>
)}
{calibrating && (
<div className="shrink-0 rounded-[var(--radius)] border border-border bg-surface px-3 py-2 text-xs">
<div className="text-accent">{t('liveMap.calibActive')}</div>
<div className="text-muted">{t('liveMap.calibPoints', { n: calibPoints.length })}</div>
{solvedStr && <div className="mt-1 font-mono text-foreground break-all">{solvedStr}</div>}
</div>
)}
<div className="flex flex-1 min-h-0 gap-0 overflow-hidden">
<FilterPanel
filter={filter}
onToggle={toggleFilter}
onClear={clearFilters}
spawns={spawns}
mapKey={mapKey}
heatmapMode={heatmapMode}
onHeatmapToggle={() => setHeatmapMode((v) => !v)}
/>
{unsupported
? <div className="flex-1 py-8 text-center text-sm text-muted">{t('liveMap.unsupported')}</div>
: (
<div className="relative flex-1 min-h-0 overflow-hidden rounded-[var(--radius)] border border-border">
<MapContainer
crs={CRS.Simple}
bounds={IMAGE_BOUNDS}
minZoom={-3}
maxZoom={4}
zoomSnap={0.25}
attributionControl={false}
style={{ height: '100%', width: '100%', background: 'var(--color-surface)', cursor: mapCursor }}
>
<InvalidateOnActive active={isActive} />
<MapClickCapture active={calibrating || teleportMode} onPick={handleMapClick} />
{effCfg.tileId
? <MapTileLayer key={mapKey} tileId={effCfg.tileId} />
: effCfg.image && (
<ImageOverlay
key={mapKey}
url={mapUrl(`map-data/${effCfg.image}`)}
bounds={IMAGE_BOUNDS}
/>
)}
{effCfg.depthFile && (
<ImageOverlay
key={`depth-${mapKey}`}
url={mapUrl(`map-data/${effCfg.depthFile}`)}
bounds={IMAGE_BOUNDS}
className="leaflet-depth-overlay"
/>
)}
<FitBoundsController fitRef={fitBoundsRef} />
{mapKey === 'DeepDesert' && (
<ZoneGridLayer effCfg={effCfg} />
)}
{heatmapMode && (
<HeatmapCanvasLayer
mapKey={mapKey}
effCfg={effCfg}
filter={filter}
/>
)}
<SpawnCanvasLayer
spawns={spawns}
effCfg={effCfg}
filter={filter}
heatmapMode={heatmapMode}
/>
{(filter.players || filter.vehicles) && orderedLive
.filter((m) => m.type === 'player' ? filter.players : m.type === 'vehicle' ? filter.vehicles : false)
.map((m) => {
const { center, isPlayer, size, icon, selectedIcon } = m
const isSelected = m.fls_id === selectedFlsId
return (
<Marker
key={`${m.type}-${m.id}`}
position={center}
icon={isSelected ? selectedIcon : icon}
draggable={isPlayer}
eventHandlers={{
click: () => {
if (m.fls_id) {
setSelectedFlsId((prev) => prev === m.fls_id ? '' : m.fls_id!)
setTeleportFlsId(m.fls_id!)
}
},
dragstart: () => { isDragging.current = true },
dragend: (e) => {
isDragging.current = false
if (!m.fls_id) return
const marker = e.target as L.Marker
const { lat, lng } = marker.getLatLng()
marker.setLatLng(center)
const { x, y } = latLngToWorld(lat, lng, effCfg)
setDragConfirm({
flsId: m.fls_id!,
name: m.name || m.fls_id!,
x: Math.round(x),
y: Math.round(y),
})
},
}}
>
<Tooltip direction="top" offset={[0, -(size / 2)]}>
<div className="font-medium">{m.name || `${m.type} ${m.id}`}</div>
<div className="text-xs opacity-70">
{m.type}
{m.online_status ? ` · ${m.online_status}` : ''}
</div>
<div className="text-xs font-mono">
{Math.round(m.x)}
{', '}
{Math.round(m.y)}
</div>
{isPlayer && <div className="text-xs text-accent mt-0.5">Drag to teleport</div>}
</Tooltip>
</Marker>
)
})}
{filter.bases && orderedLive
.filter((m) => m.type === 'base')
.map((m) => {
const { center, size, icon } = m
return (
<Marker
key={`base-${m.id}`}
position={center}
icon={icon}
>
<Tooltip direction="top" offset={[0, -(size / 2)]}>
<div className="font-medium">{m.name || `Base ${m.id}`}</div>
<div className="text-xs opacity-70">base</div>
<div className="text-xs font-mono">
{Math.round(m.x)}
{', '}
{Math.round(m.y)}
</div>
</Tooltip>
</Marker>
)
})}
{teleportDest && (
<CircleMarker
center={worldToLatLng(teleportDest.x, teleportDest.y, effCfg)}
radius={10}
pathOptions={{ color: '#ffffff', weight: 2, fillColor: '#f59e0b', fillOpacity: 0.85 }}
>
<Tooltip permanent>
<span className="text-xs">
{teleportDest.x}
,
{' '}
{teleportDest.y}
</span>
</Tooltip>
</CircleMarker>
)}
{calibrating && calibPoints.map((p, i) => (
<CircleMarker
key={`calib-${i}`}
center={[p.fracYup * IMG_H, p.fracX * IMG_W]}
radius={5}
pathOptions={{ color: '#ffffff', weight: 2, fillColor: '#ff2bd6', fillOpacity: 0.9 }}
>
<Tooltip>{`calib ${i + 1}`}</Tooltip>
</CircleMarker>
))}
</MapContainer>
</div>
)}
</div>
<ConfirmDialog
open={dragConfirm !== null}
title={t('liveMap.dragTeleportTitle', { name: dragConfirm?.name ?? '' })}
description={t('liveMap.dragTeleportDesc', { x: dragConfirm?.x ?? 0, y: dragConfirm?.y ?? 0 })}
confirmLabel={t('liveMap.teleportHere')}
onConfirm={async () => {
if (!dragConfirm) return
try {
await api.players.teleportCoords(dragConfirm.flsId, dragConfirm.x, dragConfirm.y, 5000)
toast.success(t('liveMap.teleportSent'))
}
catch (err) {
toast.danger(err instanceof Error ? err.message : String(err))
}
setDragConfirm(null)
}}
onCancel={() => setDragConfirm(null)}
/>
</div>
)
}

View File

@@ -0,0 +1,92 @@
export type Bounds = {
minX: number
maxX: number
minY: number
maxY: number
flipX?: boolean
flipY?: boolean
}
export type MapCfg = Bounds & {
key: string
label: string
image?: string
spawnFile?: string
hasLiveData?: boolean
tileId?: string
depthFile?: string
}
export type CalibPoint = {
wx: number
wy: number
fracX: number
fracYup: number
}
export type SpawnEntry = {
type: string
label?: string
category: string
x: number
y: number
z?: number
density?: number
}
export type SpawnFile = {
spawns: SpawnEntry[]
}
export interface InvalidateOnActiveProps {
active: boolean
}
export interface MapClickCaptureProps {
active: boolean
onPick: (lat: number, lng: number) => void
}
export interface SpriteIconProps {
type: string
size?: number
}
export interface SpawnCanvasLayerProps {
spawns: SpawnEntry[]
effCfg: MapCfg
filter: Record<string, boolean>
heatmapMode: boolean
}
export interface HeatmapCanvasLayerProps {
mapKey: string
effCfg: MapCfg
filter: Record<string, boolean>
}
export interface MapTileLayerProps {
tileId: string
}
export interface ZoneGridLayerProps {
effCfg: MapCfg
}
export interface FitBoundsControllerProps {
fitRef: React.MutableRefObject<(() => void) | null>
}
export interface FilterPanelProps {
filter: Record<string, boolean>
onToggle: (key: string, currentVisual: boolean) => void
onClear: () => void
spawns: SpawnEntry[]
mapKey: string
heatmapMode: boolean
onHeatmapToggle: () => void
}
export interface LiveMapTabProps {
isActive?: boolean
}

View File

@@ -0,0 +1,91 @@
import type { Bounds, CalibPoint } from './types'
import { TYPE_MERGE_KEY, IMG_W, IMG_H, HEATMAP_TO_FILTER } from './constants'
const MAP_BASE = ((import.meta.env.VITE_CDN_BASE_URL as string) ?? 'https://assets.dune.layout.tools').replace(/\/$/, '')
export function mapUrl(path: string): string {
return `${MAP_BASE}/${path}`
}
export function filterKey(type: string): string {
return TYPE_MERGE_KEY[type] ?? type
}
export function heatmapFilterKey(type: string): string {
return HEATMAP_TO_FILTER[type] ?? type
}
export function clamp01(v: number): number {
if (v < 0) return 0
if (v > 1) return 1
return v
}
export function worldToLatLng(x: number, y: number, cfg: Bounds): [number, number] {
const normX = (x - cfg.minX) / (cfg.maxX - cfg.minX)
const normY = (y - cfg.minY) / (cfg.maxY - cfg.minY)
const fracX = clamp01(cfg.flipX ? 1 - normX : normX)
const fracYup = clamp01(cfg.flipY ? 1 - normY : normY)
return [fracYup * IMG_H, fracX * IMG_W]
}
export function latLngToWorld(lat: number, lng: number, cfg: Bounds): { x: number, y: number } {
const fracX = lng / IMG_W
const fracYup = lat / IMG_H
const rawX = cfg.flipX ? 1 - fracX : fracX
const rawY = cfg.flipY ? 1 - fracYup : fracYup
return {
x: rawX * (cfg.maxX - cfg.minX) + cfg.minX,
y: rawY * (cfg.maxY - cfg.minY) + cfg.minY,
}
}
export function solveBounds(pts: CalibPoint[]): Bounds | null {
if (pts.length < 2) return null
const a = pts[0]
const b = pts[pts.length - 1]
if (b.wx === a.wx || b.wy === a.wy || b.fracX === a.fracX || b.fracYup === a.fracYup) return null
const sX = (b.fracX - a.fracX) / (b.wx - a.wx)
const iX = a.fracX - sX * a.wx
const sY = (b.fracYup - a.fracYup) / (b.wy - a.wy)
const iY = a.fracYup - sY * a.wy
const flipY = sY < 0
const minX = -iX / sX
const maxX = (1 - iX) / sX
const R = flipY ? -1 / sY : 1 / sY
const minY = flipY ? (iY - 1) * R : -iY * R
return { minX, maxX, minY, maxY: minY + R, flipY }
}
const CALIB_LS_KEY = 'dune_admin_livemap_calib'
export function loadCalib(): Record<string, Bounds> {
try {
return JSON.parse(localStorage.getItem(CALIB_LS_KEY) ?? '{}') as Record<string, Bounds>
}
catch {
return {}
}
}
const LIVE_FILTER_DEFAULTS: Record<string, boolean> = {
players: true, vehicles: true, bases: true,
}
const FILTER_LS_KEY = 'dune_admin_livemap_filter'
export function loadFilter(): Record<string, boolean> {
try {
const saved = JSON.parse(localStorage.getItem(FILTER_LS_KEY) ?? '{}') as Record<string, boolean>
return { ...LIVE_FILTER_DEFAULTS, ...saved }
}
catch {
return LIVE_FILTER_DEFAULTS
}
}
export function saveFilter(f: Record<string, boolean>): void {
try {
localStorage.setItem(FILTER_LS_KEY, JSON.stringify(f))
}
catch { /* quota */ }
}

View File

@@ -0,0 +1,329 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import type React from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Chip, Spinner, Switch, toast } from '@heroui/react'
import { api, getWsBase } from '../api/client'
import type { LogPod, CheatEntry } from '../api/client'
import { DataTable, Icon, LoadingState, SideNav, type Column } from '../dune-ui'
type ActiveView = 'pod' | 'cheats'
type NavKey = 'cheats' | `pod:${string}`
type CheatKey = 'time' | 'character' | 'cheat_type'
interface LogsTabProps {
control?: string
}
export const LogsTab: React.FC<LogsTabProps> = ({ control }) => {
const { t } = useTranslation()
// Control planes that surface log files (amp, docker, local) get
// file-oriented labels; kubectl keeps "Pods".
const isFileBased = control === 'amp' || control === 'docker' || control === 'local'
const sourceLabel = isFileBased ? t('logs.logFiles') : t('logs.pods')
const itemLabel = isFileBased ? t('logs.logFileSingular') : t('logs.podSingular')
const CHEAT_COLUMNS: Column<CheatKey>[] = [
{ key: 'time', label: t('logs.columns.time'), width: 180 },
{ key: 'character', label: t('logs.columns.character'), minWidth: 200 },
{ key: 'cheat_type', label: t('logs.columns.cheatType'), minWidth: 200 },
]
const [pods, setPods] = useState<LogPod[]>([])
const [podsLoading, setPodsLoading] = useState(false)
const [selectedPod, setSelectedPod] = useState<LogPod | null>(null)
const [connected, setConnected] = useState(false)
const [autoScroll, setAutoScroll] = useState(true)
const [displayLines, setDisplayLines] = useState<string[]>([])
const [activeView, setActiveView] = useState<ActiveView>('pod')
const [cheats, setCheats] = useState<CheatEntry[]>([])
const [cheatsLoading, setCheatsLoading] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
const linesRef = useRef<string[]>([])
const flushTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const logContainerRef = useRef<HTMLPreElement | null>(null)
const loadPods = useCallback(() => {
Promise.resolve()
.then(() => setPodsLoading(true))
.then(() => api.logs.pods())
.then(setPods)
.catch((e: unknown) => toast.danger(t('logs.failedToLoad', { message: e instanceof Error ? e.message : String(e) })))
.finally(() => setPodsLoading(false))
}, [t])
useEffect(() => {
loadPods()
}, [loadPods])
const startFlush = useCallback(() => {
if (flushTimerRef.current) return
flushTimerRef.current = setInterval(() => {
if (linesRef.current.length > 0) {
setDisplayLines((prev) => {
const combined = [...prev, ...linesRef.current]
return combined.length > 5000 ? combined.slice(combined.length - 5000) : combined
})
linesRef.current = []
}
}, 200)
}, [])
const stopFlush = useCallback(() => {
if (flushTimerRef.current) {
clearInterval(flushTimerRef.current)
flushTimerRef.current = null
}
}, [])
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
}
}, [displayLines, autoScroll])
const connectPod = useCallback((pod: LogPod) => {
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
stopFlush()
linesRef.current = []
setDisplayLines([])
setConnected(false)
setSelectedPod(pod)
setActiveView('pod')
const url = `${getWsBase()}/logs/stream?ns=${encodeURIComponent(pod.namespace)}&pod=${encodeURIComponent(pod.name)}`
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => {
setConnected(true)
startFlush()
}
ws.onmessage = (event: MessageEvent) => {
linesRef.current.push(event.data as string)
}
ws.onerror = () => {
toast.danger(t('logs.wsError'))
}
ws.onclose = () => {
setConnected(false)
stopFlush()
if (linesRef.current.length > 0) {
setDisplayLines((prev) => [...prev, ...linesRef.current])
linesRef.current = []
}
}
}, [startFlush, stopFlush, t])
const disconnect = useCallback(() => {
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
stopFlush()
setConnected(false)
}, [stopFlush])
useEffect(() => () => {
disconnect()
}, [disconnect])
const exportLogs = () => {
const blob = new Blob([displayLines.join('\n')], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${selectedPod?.name ?? 'logs'}-${new Date().toISOString()}.txt`
a.click()
URL.revokeObjectURL(url)
}
const loadCheats = async () => {
setCheatsLoading(true)
try {
setCheats(await api.logs.cheats())
}
catch (e: unknown) {
toast.danger(e instanceof Error ? e.message : String(e))
}
finally {
setCheatsLoading(false)
}
}
const navItems = [
{ key: 'cheats' as NavKey, label: t('logs.cheats7d'), sublabel: t('logs.antiCheatLog') },
...pods.map((p) => ({
key: `pod:${p.namespace}/${p.name}` as NavKey,
label: <span className="font-mono">{p.name}</span>,
sublabel: p.namespace,
})),
]
const activeKey: NavKey | null = activeView === 'cheats'
? 'cheats'
: selectedPod ? `pod:${selectedPod.namespace}/${selectedPod.name}` : null
const handleNavSelect = (key: NavKey) => {
if (key === 'cheats') {
setSelectedPod(null)
setActiveView('cheats')
loadCheats()
}
else {
const id = key.slice(4) // strip "pod:"
const pod = pods.find((p) => `${p.namespace}/${p.name}` === id)
if (pod) connectPod(pod)
}
}
return (
<div className="flex h-full gap-3 min-h-0">
<SideNav
items={navItems}
active={activeKey}
onSelect={handleNavSelect}
title={t('logs.sourceTitle', { label: sourceLabel, count: pods.length })}
titleAction={(
<Button size="sm" variant="ghost" isDisabled={podsLoading} onPress={loadPods}>
{podsLoading ? <Spinner size="sm" color="current" /> : <Icon name="refresh-cw" />}
</Button>
)}
/>
<div className="flex-1 flex flex-col overflow-hidden gap-3 min-h-0">
{activeView === 'cheats'
? (
<>
<div className="flex items-center gap-3 shrink-0">
<h3 className="text-base font-semibold text-accent flex-1">{t('logs.antiCheatTitle')}</h3>
<span className="text-xs text-muted">
{t('logs.eventsCount', { count: cheats.length })}
</span>
<Button size="sm" variant="outline" onPress={loadCheats} isDisabled={cheatsLoading}>
{cheatsLoading
? <Spinner size="sm" color="current" />
: (
<>
<Icon name="refresh-cw" />
{' '}
{t('common.refresh')}
</>
)}
</Button>
</div>
{cheatsLoading
? (
<LoadingState />
)
: (
<DataTable<CheatEntry, CheatKey>
aria-label={t('logs.antiCheatLabel')}
className="min-h-0 max-h-full"
columns={CHEAT_COLUMNS}
rows={cheats}
rowId={(c) => `${c.fls_id}-${c.event_time}-${c.cheat_type}`}
initialSort={{ column: 'time', direction: 'descending' }}
sortValue={(c, k) => {
if (k === 'time') return c.event_time
if (k === 'character') return c.character_name
return c.cheat_type
}}
emptyState={<div className="py-8 text-center text-muted">{t('logs.noCheatEvents')}</div>}
renderCell={(c, key) => {
switch (key) {
case 'time': return <span className="font-mono text-muted">{c.event_time}</span>
case 'character': return c.character_name
case 'cheat_type': {
const suspicious = /dup|negative/i.test(c.cheat_type)
return (
<Chip size="sm" color={suspicious ? 'danger' : 'default'} variant="soft">
{c.cheat_type}
</Chip>
)
}
}
}}
/>
)}
</>
)
: (
<>
<div className="flex items-center gap-3 shrink-0">
<Chip
size="sm"
color={connected ? 'success' : 'default'}
variant="soft"
>
{connected
? t('logs.connectedPod', { pod: selectedPod?.name })
: selectedPod
? t('logs.disconnected')
: t('logs.selectSource', { label: itemLabel })}
</Chip>
<div className="flex-1" />
<Switch isSelected={autoScroll} onChange={setAutoScroll} size="sm">
<Switch.Control><Switch.Thumb /></Switch.Control>
<Switch.Content>{t('logs.autoScroll')}</Switch.Content>
</Switch>
{selectedPod && connected && (
<Button size="sm" variant="danger-soft" onPress={disconnect}>
<Icon name="square" />
{' '}
{t('logs.stop')}
</Button>
)}
{selectedPod && !connected && (
<Button size="sm" variant="outline" onPress={() => connectPod(selectedPod)}>
<Icon name="play" />
{' '}
{t('logs.reconnect')}
</Button>
)}
{displayLines.length > 0 && (
<Button size="sm" variant="ghost" onPress={exportLogs}>
<Icon name="download" />
{' '}
{t('common.export')}
</Button>
)}
{displayLines.length > 0 && (
<Button
size="sm"
variant="ghost"
onPress={() => {
setDisplayLines([])
linesRef.current = []
}}
>
<Icon name="trash-2" />
{' '}
{t('logs.clear')}
</Button>
)}
<span className="text-xs text-muted">
{t('logs.linesCount', { count: displayLines.length })}
</span>
</div>
<pre
ref={logContainerRef}
className="flex-1 overflow-auto p-4 text-xs font-mono m-0 whitespace-pre-wrap break-all rounded-[var(--radius)] border border-border/60 bg-background text-success"
>
{displayLines.length === 0
? (selectedPod
? (connected ? t('logs.waitingForLines') : t('logs.disconnectedState'))
: t('logs.selectFromPanel', { label: itemLabel }))
: displayLines.join('\n')}
</pre>
</>
)}
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More