Private
Public Access
1
0

feat(M1): Project scaffolding, DB schema, core infrastructure

- Initialize Rust workspace with 7 crates (pm-web, pm-worker, pm-core,
  pm-agent-client, pm-auth, pm-ca, pm-reports)
- React + TypeScript + Vite + MUI frontend scaffold
- Full PostgreSQL schema: all 17 tables with indexes and constraints
- pm-core: config (TOML+env), db (SQLx pool + migrations), error
  (unified AppError + JSON envelope), request_id (ULID middleware),
  logging (tracing JSON/pretty)
- pm-web: Axum skeleton, /status/health endpoint, static file serving
- pm-worker: Tokio skeleton, heartbeat writer, schema version check
- Embedded sqlx migrations with advisory lock (single-writer)
- systemd unit files, setup.sh, build-frontend.sh
- config.example.toml with all configuration keys
- docs/runbooks/restore.md
- cargo check passes with zero warnings

Closes M1.
This commit is contained in:
2026-04-23 15:55:53 +00:00
parent 3eb7fd9f95
commit da5a94d838
50 changed files with 6139 additions and 3 deletions

15
frontend/index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' wss:;" />
<title>Linux Patch Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
frontend/package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "patch-manager-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.0.0",
"@mui/material": "^7.0.0",
"axios": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.5.3",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^8.30.0",
"@typescript-eslint/parser": "^8.30.0",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.24.0",
"typescript": "^5.8.3",
"vite": "^6.3.3"
}
}

37
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,37 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { CssBaseline, ThemeProvider } from '@mui/material'
import { lightTheme } from './theme/theme'
// Placeholder pages — implemented in M2+
const PlaceholderPage = ({ title }: { title: string }) => (
<div style={{ padding: 32 }}>
<h2>{title}</h2>
<p>Coming soon in a future milestone.</p>
</div>
)
function App() {
return (
<ThemeProvider theme={lightTheme}>
<CssBaseline />
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<PlaceholderPage title="Dashboard" />} />
<Route path="/hosts" element={<PlaceholderPage title="Hosts" />} />
<Route path="/hosts/:id" element={<PlaceholderPage title="Host Detail" />} />
<Route path="/jobs" element={<PlaceholderPage title="Jobs" />} />
<Route path="/deployment" element={<PlaceholderPage title="Patch Deployment" />} />
<Route path="/maintenance" element={<PlaceholderPage title="Maintenance Windows" />} />
<Route path="/groups" element={<PlaceholderPage title="Groups" />} />
<Route path="/reports" element={<PlaceholderPage title="Reports" />} />
<Route path="/users" element={<PlaceholderPage title="Users" />} />
<Route path="/certificates" element={<PlaceholderPage title="Certificates" />} />
<Route path="/settings" element={<PlaceholderPage title="Settings" />} />
<Route path="/login" element={<PlaceholderPage title="Login" />} />
<Route path="*" element={<PlaceholderPage title="404 Not Found" />} />
</Routes>
</ThemeProvider>
)
}
export default App

View File

@ -0,0 +1,8 @@
import axios from 'axios'
// Base API client — JWT interceptors added in M2
export const apiClient = axios.create({
baseURL: '/api/v1',
headers: { 'Content-Type': 'application/json' },
timeout: 30_000,
})

2
frontend/src/index.css Normal file
View File

@ -0,0 +1,2 @@
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: 'Roboto', sans-serif; }

13
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
)

View File

@ -0,0 +1,23 @@
import { createTheme } from '@mui/material/styles'
export const lightTheme = createTheme({
palette: {
mode: 'light',
primary: { main: '#1565C0' },
secondary: { main: '#0288D1' },
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
},
})
export const darkTheme = createTheme({
palette: {
mode: 'dark',
primary: { main: '#42A5F5' },
secondary: { main: '#26C6DA' },
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
},
})

View File

@ -0,0 +1,47 @@
// Core TypeScript types — expanded per milestone
export type UserRole = 'admin' | 'operator'
export type AuthProvider = 'local' | 'azure_sso'
export type HostHealthStatus = 'pending' | 'healthy' | 'degraded' | 'unreachable'
export type JobStatus = 'queued' | 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled'
export type JobKind = 'patch_apply' | 'patch_remove' | 'reboot' | 'rollback'
export interface ApiError {
error: {
code: string
message: string
request_id?: string
details?: unknown
}
}
export interface Host {
id: string
fqdn: string
ip_address: string
display_name: string
health_status: HostHealthStatus
os_family?: string
os_name?: string
agent_version?: string
registered_at: string
}
export interface Group {
id: string
name: string
description: string
created_at: string
}
export interface User {
id: string
username: string
display_name: string
email: string
role: UserRole
auth_provider: AuthProvider
mfa_enabled: boolean
is_active: boolean
last_login_at?: string
}

25
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

37
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,37 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/status': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
mui: ['@mui/material', '@mui/icons-material'],
},
},
},
},
})