Initial commit

This commit is contained in:
2026-04-28 10:29:02 +02:00
parent e8636b08be
commit f22a25ce46
26 changed files with 3951 additions and 1 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
.env

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.env
*.local

View File

@@ -1 +1,78 @@
AZion
# AZion Scheduler High-Performance Process Dashboard
AZion is a specialized Vite + React SPA designed for high-performance visualization and management of server-side processes, using **Baserow** as a headless backend.
## 🚀 Quick Start
1. **Install dependencies**: `npm install`
2. **Setup environment**: Rename `.env.example` to `.env` and provide your Baserow API details.
3. **Run Dev server**: `npm run dev`
---
## 🛠 Baserow & n8n Integration Guide
This section outlines the exact internal logic the AZion dashboard uses to parse scheduling parameters. When inserting or updating records via an automation runtime like **n8n**, your JSON payload sent to the Baserow API must structurally respect these dependencies.
### 1. Global / Universal Fields
These fields are required or heavily impact the UI regardless of the execution type.
- **`Name`** *(Single-line text)*: The display name of the process.
- **`Server`** *(Linked row)*: Reference ID linking back to the Server Table. n8n payload syntax: `[123]` (Array of IDs).
- **`Sortierung`** *(Number)*: The rendering order integer. `1` is pushed to the top of the timeline track.
- **`Dauer`** *(Single-line text or Number)*: **Always specified in SECONDS**. Defines the length of the visual block. *(e.g., passing `"1800"` renders a 30-minute block).*
- **`Farbe`** *(Single-line text)*: The hex code for the visual block. *(e.g., `"#3b82f6"`).*
- **`Status`** *(Single select)*: E.g., `aktiv`, `inaktiv`, `fehler`.
- **`Sichtbarkeit`** *(Single select)*: Declares semantic zoom constraints (`Immer`, `Max 6 Stunden`, `Max 1 Tag`, `Max 1 Woche`, `Max 1 Monat`).
### 2. Dynamic Scheduling via `Wiederholung`
The logic engine evaluates time solely based on what string is stored inside the **`Wiederholung`** *(Single select)* column.
#### Pattern A: Standard Execution
**Applies to**: `TAEGLICH`, `WOECHENTLICH`, `WERKTAGE`, `WOCHENENDE`, `MONATLICH_FESTER_TAG`
These patterns expect a singular, definitive time of day.
* **n8n Setup**:
* `Wiederholung`: `"TAEGLICH"`
* `Start`: `"HH:MM:SS"` *(e.g., `"08:30:00"`).*
* `Dauer`: `"60"` *(60 seconds).*
#### Pattern B: Hourly Execution (`STUENDLICH`)
* **n8n Setup**:
* `Wiederholung`: `"STUENDLICH"`
* `Start Minute`: `45` *(Integer 059).*
* `Dauer`: `"120"` *(2 minutes).*
#### Pattern C: Custom Interval (`INTERVALL`)
Recurrent blocks bounded between two timestamps.
* **n8n Setup**:
* `Wiederholung`: `"INTERVALL"`
* `Erste Ausführung`: `"HH:MM:SS"` *(Start bounds).*
* `Ausführung bis`: `"HH:MM:SS"` *(End bounds).*
* `Intervall zwischen`: `"3600"` *(Spacing in SECONDS).*
* `Dauer`: `"300"` *(Duration in SECONDS).*
---
### 3. Example JSON Payload (n8n / API)
```json
{
"Name": "API Heartbeat Checker",
"Server": [45],
"Wiederholung": "INTERVALL",
"Erste Ausführung": "08:00:00",
"Ausführung bis": "17:00:00",
"Intervall zwischen": "300",
"Dauer": "15",
"Status": "aktiv",
"Farbe": "#22c55e",
"Sortierung": 8
}
```
### 🧠 Performance & Logic Notes
- **Block Merging**: The Gantt Chart automatically merges overlapping blocks from the same process to prevent DOM flooding when zoomed out.
- **Max View Filter**: High-frequency tasks (like 1-min intervals) are hidden in Monthly/Weekly views unless `Sichtbarkeit` is set to "Immer" to maintain UI responsiveness.
- **Auto-Sort**: The frontend automatically finds the next highest `Sortierung` index when creating new tasks via the GUI.

26
check-ui.js Normal file
View File

@@ -0,0 +1,26 @@
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
page.on('pageerror', err => console.log('Runtime Error:', err.message));
page.on('console', msg => console.log('Console:', msg.text()));
await page.goto('http://localhost:3001');
await page.waitForTimeout(2000);
// Evaluate if Gantt DOM structural integrity exists
const ganttMetrics = await page.evaluate(() => {
const blocks = document.querySelectorAll('.gantt-block');
const rows = document.querySelectorAll('.gantt-row');
return {
blockCount: blocks.length,
rowCount: rows.length,
blocksWidth: Array.from(blocks).slice(0,5).map(b => b.style.width),
blocksLeft: Array.from(blocks).slice(0,5).map(b => b.style.left)
};
});
console.log("Metrics:", JSON.stringify(ganttMetrics, null, 2));
await browser.close();
})();

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>AZion Scheduler AZ INTEC GmbH</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

1771
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "azion-scheduler",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.3.4"
}
}

1
public/.gitkeep Normal file
View File

@@ -0,0 +1 @@
# Empty Gitkeep File

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

37
src/App.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react'
import { Routes, Route } from 'react-router-dom'
import Sidebar from './components/Sidebar'
import Timeline from './pages/Timeline'
import Servers from './pages/Servers'
import Processes from './pages/Processes'
export default function App() {
const [now, setNow] = useState(new Date())
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(timer)
}, [])
return (
<div className="app-layout">
<Sidebar />
<main className="main">
<div className="topbar">
<div className="status" style={{ fontWeight: 600, fontFamily: 'var(--font)', letterSpacing: '0.01em' }}>
<span className="status-dot" />
{now.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} {now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</div>
<span className="badge">AZ INTEC GmbH</span>
</div>
<div className="main-content">
<Routes>
<Route path="/" element={<Timeline />} />
<Route path="/servers" element={<Servers />} />
<Route path="/processes" element={<Processes />} />
</Routes>
</div>
</main>
</div>
)
}

104
src/api/baserow.ts Normal file
View File

@@ -0,0 +1,104 @@
const BASE = import.meta.env.VITE_BASEROW_URL;
const TOKEN = import.meta.env.VITE_BASEROW_TOKEN;
const SERVERS_TABLE = import.meta.env.VITE_SERVERS_TABLE_ID;
const PROCESSES_TABLE = import.meta.env.VITE_PROCESSES_TABLE_ID;
const headers = {
Authorization: `Token ${TOKEN}`,
'Content-Type': 'application/json',
};
/* ── Types ─────────────────────────────────────────── */
export interface Server {
id: number;
Name: string;
Beschreibung: string;
Sortierung?: number;
Prozesse: { id: number; value: string }[];
}
export interface Process {
id: number;
Name: string;
Server?: { id: number; value: string }[];
Status?: { id: number; value: string; color: string };
Wiederholung?: { id: number; value: string; color: string };
Farbe?: string;
Start?: string;
Ende?: string;
'Start Minute'?: number | null;
Dauer?: string;
'Erste Ausführung'?: string;
'Ausführung bis'?: string;
'Intervall zwischen'?: string;
Wochentage?: string;
Sichtbarkeit?: { id: number; value: string; color: string };
Sortierung?: number;
}
interface BaserowList<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
/* ── Generic helpers ───────────────────────────────── */
async function listRows<T>(tableId: string): Promise<T[]> {
const res = await fetch(
`${BASE}/api/database/rows/table/${tableId}/?user_field_names=true&size=200`,
{ headers }
);
if (!res.ok) throw new Error(`Baserow GET failed: ${res.status}`);
const data: BaserowList<T> = await res.json();
return data.results;
}
async function createRow(tableId: string, fields: Record<string, unknown>) {
const res = await fetch(
`${BASE}/api/database/rows/table/${tableId}/?user_field_names=true`,
{ method: 'POST', headers, body: JSON.stringify(fields) }
);
if (!res.ok) throw new Error(`Baserow POST failed: ${res.status}`);
return res.json();
}
async function updateRow(tableId: string, rowId: number, fields: Record<string, unknown>) {
const res = await fetch(
`${BASE}/api/database/rows/table/${tableId}/${rowId}/?user_field_names=true`,
{ method: 'PATCH', headers, body: JSON.stringify(fields) }
);
if (!res.ok) throw new Error(`Baserow PATCH failed: ${res.status}`);
return res.json();
}
async function deleteRow(tableId: string, rowId: number) {
const res = await fetch(
`${BASE}/api/database/rows/table/${tableId}/${rowId}/`,
{ method: 'DELETE', headers }
);
if (!res.ok) throw new Error(`Baserow DELETE failed: ${res.status}`);
}
/* ── Public API ────────────────────────────────────── */
export const api = {
// Servers
getServers: () => listRows<Server>(SERVERS_TABLE),
createServer: (name: string, description: string) =>
createRow(SERVERS_TABLE, { Name: name, Beschreibung: description }),
deleteServer: (id: number) => deleteRow(SERVERS_TABLE, id),
// Processes
getProcesses: () => listRows<Process>(PROCESSES_TABLE),
createProcess: (fields: Record<string, unknown>) =>
createRow(PROCESSES_TABLE, fields),
updateProcess: (id: number, fields: Record<string, unknown>) => {
// If fields.Sichtbarkeit is undefined, it won't be sent, which protects against the field missing.
const cleanFields = Object.fromEntries(Object.entries(fields).filter(([_, v]) => v !== undefined));
return updateRow(PROCESSES_TABLE, id, cleanFields);
},
deleteProcess: (id: number) => deleteRow(PROCESSES_TABLE, id),
};

View File

@@ -0,0 +1,457 @@
import React, { useState, useEffect } from 'react'
import type { Process, Server } from '../api/baserow'
const SERVER_ICON = (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
)
type ViewMode = '2H' | '6H' | 'Arbeitszeit' | 'Nachts' | 'Tag' | 'Woche' | 'Monat'
function getViewBounds(mode: ViewMode, referenceDate: Date): { start: Date, end: Date, spanHours: number } {
const d = new Date(referenceDate)
let start = new Date(d)
let end = new Date(d)
let spanHours = 0
if (mode === '2H') {
start = new Date(d.getTime() - 1 * 3600_000)
end = new Date(d.getTime() + 1 * 3600_000)
spanHours = 2
} else if (mode === '6H') {
start = new Date(d.getTime() - 3 * 3600_000)
end = new Date(d.getTime() + 3 * 3600_000)
spanHours = 6
} else if (mode === 'Arbeitszeit') {
start.setHours(6, 0, 0, 0)
end.setHours(18, 0, 0, 0)
spanHours = 12
} else if (mode === 'Nachts') {
if (d.getHours() < 6) d.setDate(d.getDate() - 1)
start = new Date(d)
start.setHours(18, 0, 0, 0)
end = new Date(start.getTime() + 12 * 3600_000)
spanHours = 12
} else if (mode === 'Tag') {
start.setHours(0, 0, 0, 0)
end = new Date(start.getTime() + 24 * 3600_000)
spanHours = 24
} else if (mode === 'Woche') {
const day = start.getDay()
const diff = start.getDate() - day + (day === 0 ? -6 : 1)
start.setDate(diff)
start.setHours(0, 0, 0, 0)
end = new Date(start.getTime() + 7 * 24 * 3600_000)
spanHours = 168
} else if (mode === 'Monat') {
start.setDate(1)
start.setHours(0, 0, 0, 0)
end = new Date(start.getTime())
end.setMonth(end.getMonth() + 1)
spanHours = (end.getTime() - start.getTime()) / 3600_000
}
return { start, end, spanHours }
}
function parseTime(tStr: string | null | undefined): [number, number, number] {
if (!tStr) return [12, 0, 0]
const [h, m, s] = tStr.split(':').map(Number)
return [isNaN(h) ? 12 : h, isNaN(m) ? 0 : m, isNaN(s) ? 0 : s]
}
export default function GanttChart({ processes, servers, onRunningChange }: { processes: Process[], servers?: Server[], onRunningChange?: (count: number) => void }) {
const [view, setView] = useState<ViewMode>('Tag')
const [now, setNow] = useState(new Date())
useEffect(() => {
const interval = setInterval(() => setNow(new Date()), 60_000)
return () => clearInterval(interval)
}, [])
// Calculate Running Processes whenever now or processes change
useEffect(() => {
if (!onRunningChange) return
let count = 0
const nowMs = now.getTime()
for (const proc of processes) {
if (proc.Status?.value !== 'aktiv') continue
// Minimal version of renderBlocks logic to find if any block overlaps 'now'
const rep = proc.Wiederholung?.value
let isRunning = false
// We only need to check today's blocks for 'now'
const iter = new Date(now)
iter.setHours(0, 0, 0, 0)
const check = (stMs: number, durSecs: number) => {
const edMs = stMs + (durSecs * 1000)
return nowMs >= stMs && nowMs <= edMs
}
const dur = parseInt(proc.Dauer || '0') || 60
const dayStrings = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
const currentDayStr = dayStrings[iter.getDay()]
// Weekday filter
if (proc.Wochentage && proc.Wochentage.trim() !== '') {
if (!proc.Wochentage.includes(currentDayStr) && rep !== 'WOCHENENDE' && rep !== 'WERKTAGE') {
continue
}
}
if (rep === 'TAEGLICH') {
const [h, m, s] = parseTime(proc.Start)
const d = new Date(iter)
d.setHours(h, m, s)
if (check(d.getTime(), dur)) isRunning = true
} else if (rep === 'STUENDLICH') {
const d = new Date(iter)
d.setHours(now.getHours(), proc['Start Minute'] || 0, 0)
if (check(d.getTime(), dur)) isRunning = true
} else if (rep === 'WOECHENTLICH') {
const [h, m, s] = parseTime(proc.Start)
const d = new Date(iter)
d.setHours(h, m, s)
if (check(d.getTime(), dur)) isRunning = true
} else if (rep === 'WERKTAGE') {
const day = iter.getDay()
if (day >= 1 && day <= 5) {
const [h, m, s] = parseTime(proc.Start)
const d = new Date(iter)
d.setHours(h, m, s)
if (check(d.getTime(), dur)) isRunning = true
}
} else if (rep === 'WOCHENENDE') {
const day = iter.getDay()
if (day === 0 || day === 6) {
const [h, m, s] = parseTime(proc.Start)
const d = new Date(iter)
d.setHours(h, m, s)
if (check(d.getTime(), dur)) isRunning = true
}
} else if (rep === 'INTERVALL') {
const [fh, fm, fs] = parseTime(proc['Erste Ausführung'])
const [wh, wm, ws] = parseTime(proc['Ausführung bis'] || '23:59:59')
const interval = Math.max(1, parseInt(proc['Intervall zwischen'] || '0') || 60)
const dayStart = new Date(iter)
dayStart.setHours(fh, fm, fs)
const dayEnd = new Date(iter)
dayEnd.setHours(wh, wm, ws)
// Find if any interval in this day contains 'now'
if (nowMs >= dayStart.getTime() && nowMs <= dayEnd.getTime()) {
const diffSeconds = (nowMs - dayStart.getTime()) / 1000
const remainder = diffSeconds % interval
if (remainder <= dur) isRunning = true
}
} else if (rep === 'MONATLICH_FESTER_TAG') {
if (iter.getDate() === 1) {
const [h, m, s] = parseTime(proc.Start)
const d = new Date(iter)
d.setHours(h, m, s)
if (check(d.getTime(), dur)) isRunning = true
}
}
if (isRunning) count++
}
onRunningChange(count)
}, [now, processes, onRunningChange])
const { start: viewStart, end: viewEnd, spanHours } = getViewBounds(view, now)
const spanMs = viewEnd.getTime() - viewStart.getTime()
// Generate Hour Markers
const markers = []
let mkIter = new Date(viewStart)
// Dynamic Snapping
if (view === '2H') {
mkIter.setMinutes(Math.floor(mkIter.getMinutes() / 15) * 15, 0, 0)
} else if (view === '6H') {
mkIter.setMinutes(Math.floor(mkIter.getMinutes() / 30) * 30, 0, 0)
} else if (view === 'Monat') {
mkIter.setHours(0, 0, 0, 0)
} else if (view === 'Woche') {
mkIter.setHours(Math.floor(mkIter.getHours() / 6) * 6, 0, 0, 0)
} else {
mkIter.setMinutes(0, 0, 0)
}
while (mkIter <= viewEnd) {
const pct = ((mkIter.getTime() - viewStart.getTime()) / spanMs) * 100
let label = ''
if (view === 'Monat') {
label = mkIter.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
} else if (view === 'Woche') {
if (mkIter.getHours() === 0) {
label = mkIter.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })
} else {
label = mkIter.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
} else {
label = mkIter.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
if (pct >= 0 && pct <= 100) {
markers.push({ key: mkIter.getTime(), pct, label })
}
// Dynamic Increment
if (view === '2H') mkIter.setMinutes(mkIter.getMinutes() + 15)
else if (view === '6H') mkIter.setMinutes(mkIter.getMinutes() + 30)
else if (view === 'Woche') mkIter.setHours(mkIter.getHours() + 6)
else if (view === 'Monat') mkIter.setDate(mkIter.getDate() + 1)
else mkIter.setHours(mkIter.getHours() + 1)
}
// Now line
const nowPct = ((now.getTime() - viewStart.getTime()) / spanMs) * 100
const isNowVisible = nowPct >= 0 && nowPct <= 100
// Group processes by server
const serverGroups = new Map<string, Process[]>()
for (const p of processes) {
// Only render active processes
if (p.Status?.value && p.Status.value !== 'aktiv') continue
// APPLY VISIBILITY MAX VIEW FILTER (CRITICAL MEMORY PRESERVATION)
const maxView = p.Sichtbarkeit?.value || 'Immer'
if (maxView === 'Max 6 Stunden') {
if (view === 'Tag' || view === 'Woche' || view === 'Monat' || view === 'Arbeitszeit' || view === 'Nachts') continue
} else if (maxView === 'Max 1 Tag') {
if (view === 'Woche' || view === 'Monat') continue
}
const sName = p.Server?.[0]?.value || 'Ohne Server'
if (!serverGroups.has(sName)) serverGroups.set(sName, [])
serverGroups.get(sName)!.push(p)
}
const renderBlocksForProcess = (proc: Process) => {
const rawBlocks: { stMs: number; edMs: number }[] = []
const rep = proc.Wiederholung?.value
let iter = new Date(viewStart)
iter.setHours(0, 0, 0, 0)
// Add one day padding
iter.setDate(iter.getDate() - 1)
const iterEnd = new Date(viewEnd)
iterEnd.setDate(iterEnd.getDate() + 1)
const pushRawBlock = (startTimeStr: Date, durationSecs: number) => {
const stMs = startTimeStr.getTime()
const edMs = stMs + (durationSecs * 1000)
if (edMs < viewStart.getTime() || stMs > viewEnd.getTime()) return
rawBlocks.push({ stMs, edMs })
}
while (iter < iterEnd) {
// Apply weekday filter globally
const dayStrings = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
const currentDayStr = dayStrings[iter.getDay()]
if (proc.Wochentage && proc.Wochentage.trim() !== '') {
if (!proc.Wochentage.includes(currentDayStr) && rep !== 'WOCHENENDE' && rep !== 'WERKTAGE') {
iter.setDate(iter.getDate() + 1)
continue
}
}
if (rep === 'TAEGLICH') {
const [h, m, s] = parseTime(proc.Start)
const d = new Date(iter)
d.setHours(h, m, s)
pushRawBlock(d, parseInt(proc.Dauer || '0') || 60)
} else if (rep === 'STUENDLICH') {
for (let hh = 0; hh < 24; hh++) {
const d = new Date(iter)
d.setHours(hh, proc['Start Minute'] || 0, 0)
pushRawBlock(d, Math.max(1, parseInt(proc.Dauer || '0') || 60))
}
} else if (rep === 'WOECHENTLICH') {
const [h, m, s] = parseTime(proc.Start)
const d = new Date(iter)
d.setHours(h, m, s)
pushRawBlock(d, parseInt(proc.Dauer || '0') || 60)
} else if (rep === 'WERKTAGE') {
const day = iter.getDay()
if (day >= 1 && day <= 5) {
const [h, m, s] = parseTime(proc.Start)
const d = new Date(iter)
d.setHours(h, m, s)
pushRawBlock(d, parseInt(proc.Dauer || '0') || 60)
}
} else if (rep === 'WOCHENENDE') {
const day = iter.getDay()
if (day === 0 || day === 6) {
const [h, m, s] = parseTime(proc.Start)
const d = new Date(iter)
d.setHours(h, m, s)
pushRawBlock(d, parseInt(proc.Dauer || '0') || 60)
}
} else if (rep === 'INTERVALL') {
const [fh, fm, fs] = parseTime(proc['Erste Ausführung'])
const [wh, wm, ws] = parseTime(proc['Ausführung bis'] || '23:59:59')
const interval = Math.max(1, parseInt(proc['Intervall zwischen'] || '0') || 60)
const dur = parseInt(proc.Dauer || '0') || 60
const dayStart = new Date(iter)
dayStart.setHours(fh, fm, fs)
const dayEnd = new Date(iter)
dayEnd.setHours(wh, wm, ws)
for (let t = dayStart.getTime(); t <= dayEnd.getTime(); t += interval * 1000) {
pushRawBlock(new Date(t), dur)
}
} else if (rep === 'MONATLICH_FESTER_TAG') {
if (iter.getDate() === 1) {
const [h, m, s] = parseTime(proc.Start)
const d = new Date(iter)
d.setHours(h, m, s)
pushRawBlock(d, parseInt(proc.Dauer || '0') || 60)
}
} else {
const [h, m, s] = parseTime(proc.Start || '12:00:00')
const d = new Date(iter)
d.setHours(h, m, s)
pushRawBlock(d, parseInt(proc.Dauer || '0') || 30)
}
iter.setDate(iter.getDate() + 1)
}
// Sort blocks by start time
rawBlocks.sort((a, b) => a.stMs - b.stMs)
// Merge blocks that are overlapping or too close to render individually (e.g. gap < 0.2% of screen)
const mergedBlocks: { stMs: number; edMs: number }[] = []
// 0.2% of total span ms is threshold for merging
const mergeThreshold = spanMs * 0.002
for (const b of rawBlocks) {
if (mergedBlocks.length === 0) {
mergedBlocks.push({ ...b })
} else {
const prev = mergedBlocks[mergedBlocks.length - 1]
// If start is before or overlaps with prev.end + visual threshold
if (b.stMs <= prev.edMs + mergeThreshold) {
prev.edMs = Math.max(prev.edMs, b.edMs)
} else {
mergedBlocks.push({ ...b })
}
}
}
// Now map them to standard JSX layout
return mergedBlocks.map(b => {
const left = Math.max(0, ((b.stMs - viewStart.getTime()) / spanMs) * 100)
const rightBounds = Math.min(viewEnd.getTime(), b.edMs)
let width = ((rightBounds - Math.max(viewStart.getTime(), b.stMs)) / spanMs) * 100
if (width < 0.2) width = 0.2
return (
<div
key={`${b.stMs}-${b.edMs}`}
className="gantt-block"
style={{ left: `${left}%`, width: `${width}%`, backgroundColor: proc.Farbe || 'var(--blue)' }}
title={`${proc.Name}\nZusammengefasste Laufzeit: ${new Date(b.stMs).toLocaleTimeString('de-DE')} bis ${new Date(b.edMs).toLocaleTimeString('de-DE')}`}
>
{width > 5 && <span className="gantt-block-label">{proc.Name}</span>}
</div>
)
})
}
return (
<div className="gantt-container" style={{ marginTop: 24, padding: 16 }}>
{/* Zoom controls */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h2 style={{ fontSize: '1.2rem', margin: 0 }}>Timeline Setup</h2>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{(['2H', '6H', 'Arbeitszeit', 'Nachts', 'Tag', 'Woche', 'Monat'] as ViewMode[]).map(v => (
<button
key={v}
className={`btn ${view === v ? 'btn-primary' : 'btn-ghost'} btn-sm`}
onClick={() => setView(v)}
>
{v}
</button>
))}
</div>
</div>
<div className="gantt-scroll-window">
{/* Header markers */}
<div className="gantt-header">
{markers.map(m => (
<div key={m.key} className="gantt-tick" style={{ left: `${m.pct}%` }}>
<span className="gantt-tick-label">{m.label}</span>
</div>
))}
</div>
{/* Chart body */}
<div className="gantt-body">
{isNowVisible && (
<div style={{ position: 'absolute', top: 0, bottom: 0, left: 170, right: 0, pointerEvents: 'none', zIndex: 100 }}>
<div className="gantt-now-line" style={{ left: `${nowPct}%` }} />
</div>
)}
{Array.from(serverGroups.entries())
.sort(([sNameA], [sNameB]) => {
if (!servers) return 0
const sA = servers.find(s => s.Name === sNameA)
const sB = servers.find(s => s.Name === sNameB)
return (sA?.Sortierung ?? 9999) - (sB?.Sortierung ?? 9999)
})
.map(([serverName, procs]) => {
const sortedProcs = [...procs].sort((a, b) => (a.Sortierung ?? 9999) - (b.Sortierung ?? 9999))
return (
<div key={serverName} className="gantt-row">
<div className="gantt-row-info" style={{ flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 'bold' }}>
{SERVER_ICON}
<span>{serverName}</span>
</div>
{sortedProcs.length > 0 && (
<div style={{ fontSize: '9px', color: 'var(--fg-muted)', display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sortedProcs.map(p => (
<span key={p.id} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="color-dot" style={{ backgroundColor: p.Farbe || '#3b82f6', width: 6, height: 6 }} />
{p.Name}
</span>
))}
</div>
)}
</div>
<div className="gantt-bars-area">
{sortedProcs.map(p => (
<React.Fragment key={p.id}>
{renderBlocksForProcess(p)}
</React.Fragment>
))}
</div>
</div>
)})}
{serverGroups.size === 0 && (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--fg-muted)' }}>
Keine aktiven Prozesse in dieser Ansicht sichtbar.
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { useLocation, Link } from 'react-router-dom'
import { useState, useEffect } from 'react'
const navItems = [
{
href: '/',
label: 'Timeline',
icon: (
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
{
href: '/servers',
label: 'Server',
icon: (
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
),
},
{
href: '/processes',
label: 'Prozesse',
icon: (
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
),
},
]
export default function Sidebar() {
const { pathname } = useLocation()
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark')
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('theme', theme)
}, [theme])
const toggleTheme = () => setTheme(t => (t === 'dark' ? 'light' : 'dark'))
return (
<aside className="sidebar">
<div className="sidebar-logo">
<Link to="/">
<img src="/logo.png" alt="AZ INTEC" />
<span className="logo-suffix">ion</span>
</Link>
<span className="subtitle">Prozess Scheduler</span>
</div>
<nav className="sidebar-nav">
{navItems.map(({ href, label, icon }) => (
<Link key={href} to={href} className={pathname === href ? 'active' : ''}>
{icon}
{label}
</Link>
))}
</nav>
<div className="sidebar-footer">
<div className="theme-row">
<span>Erscheinungsbild</span>
<div className="theme-toggle" onClick={toggleTheme} title="Theme wechseln" />
</div>
<div className="version">
<img src="/logo.png" alt="" />
AZion v23.01
</div>
</div>
</aside>
)
}

783
src/index.css Normal file
View File

@@ -0,0 +1,783 @@
/* ══════════════════════════════════════════════════════
AZion Scheduler — Design System
Dark + Light themes, vanilla CSS
══════════════════════════════════════════════════════ */
/* ── Reset & Base ──────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--font: 'Corbel', 'Segoe UI', system-ui, -apple-system, sans-serif;
--radius: 8px;
--radius-lg: 12px;
--transition: 150ms ease;
/* ── Dark theme (default) ── */
--bg: hsl(220 16% 10%);
--bg-card: hsl(220 16% 13%);
--bg-sidebar: hsl(220 18% 8%);
--bg-topbar: hsl(220 18% 9%);
--bg-input: hsl(220 14% 16%);
--bg-hover: hsl(220 14% 18%);
--fg: hsl(210 20% 93%);
--fg-muted: hsl(215 16% 55%);
--fg-dim: hsl(215 10% 40%);
--border: hsl(220 14% 20%);
--border-light: hsl(220 14% 16%);
--primary: hsl(4 87% 47%);
--primary-fg: #fff;
--primary-dim: hsl(4 87% 47% / 0.15);
--green: hsl(142 71% 45%);
--yellow: hsl(38 92% 50%);
--blue: hsl(217 91% 60%);
color-scheme: dark;
}
/* ── Light theme ───────────────────────────────────── */
[data-theme="light"] {
--bg: hsl(220 14% 96%);
--bg-card: hsl(0 0% 100%);
--bg-sidebar: hsl(0 0% 100%);
--bg-topbar: hsl(0 0% 99%);
--bg-input: hsl(220 14% 93%);
--bg-hover: hsl(220 10% 90%);
--fg: hsl(220 25% 12%);
--fg-muted: hsl(215 16% 47%);
--fg-dim: hsl(215 10% 65%);
--border: hsl(220 14% 85%);
--border-light: hsl(220 14% 90%);
color-scheme: light;
}
html {
font-family: var(--font);
font-size: 14px;
}
body {
background: var(--bg);
color: var(--fg);
min-height: 100vh;
transition: background var(--transition), color var(--transition);
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
input,
select,
textarea {
font: inherit;
color: var(--fg);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 12px;
outline: none;
transition: border-color var(--transition);
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--primary);
}
/* ── Scrollbar ─────────────────────────────────────── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--primary);
}
/* ── App Layout ────────────────────────────────────── */
.app-layout {
display: flex;
min-height: 100vh;
}
/* ── Sidebar ───────────────────────────────────────── */
.sidebar {
width: 220px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
}
.sidebar-logo {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 0px;
}
.sidebar-logo a {
display: flex;
align-items: center;
gap: 0px;
}
.sidebar-logo img {
height: 28px;
width: auto;
}
.sidebar-logo .logo-suffix {
font-size: 42px;
font-weight: 700;
letter-spacing: -0.02em;
}
.sidebar-logo .subtitle {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--fg-muted);
font-weight: 500;
padding-left: 2px;
}
.sidebar-nav {
flex: 1;
padding: 12px 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.sidebar-nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 1000;
color: var(--fg-muted);
transition: all var(--transition);
border-left: 3px solid transparent;
}
.sidebar-nav a:hover {
background: var(--bg-hover);
color: var(--fg);
}
.sidebar-nav a.active {
background: var(--primary-dim);
color: var(--primary);
border-left-color: var(--primary);
}
.sidebar-nav a svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 10px;
}
.sidebar-footer .theme-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
color: var(--fg-muted);
font-weight: 500;
}
.sidebar-footer .version {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: var(--fg-dim);
font-family: monospace;
}
.sidebar-footer .version img {
height: 14px;
opacity: 0.6;
}
/* ── Theme Toggle ──────────────────────────────────── */
.theme-toggle {
width: 40px;
height: 22px;
border-radius: 11px;
background: var(--border);
position: relative;
transition: background var(--transition);
cursor: pointer;
}
.theme-toggle::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary);
transition: transform var(--transition);
}
[data-theme="light"] .theme-toggle::after {
transform: translateX(18px);
}
/* ── Topbar ────────────────────────────────────────── */
.topbar {
position: sticky;
top: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg-topbar);
backdrop-filter: blur(12px);
}
.topbar .status {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
color: var(--fg-muted);
}
.topbar .status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 6px hsl(142 71% 45% / 0.7);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.topbar .badge {
font-size: 12px;
font-family: monospace;
color: var(--fg-dim);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 3px 8px;
}
/* ── Main Content ──────────────────────────────────── */
.main {
flex: 1;
overflow: auto;
min-height: 100vh;
}
.main-content {
padding: 24px;
}
/* ── Page Header ───────────────────────────────────── */
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 24px;
}
.page-header h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
}
.page-header p {
font-size: 13px;
color: var(--fg-muted);
margin-top: 2px;
}
/* ── Buttons ───────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius);
font-size: 16px;
font-weight: 600;
transition: all var(--transition);
}
.btn-primary {
background: var(--primary);
color: var(--primary-fg);
box-shadow: 0 1px 3px hsl(4 87% 47% / 0.3);
}
.btn-primary:hover {
filter: brightness(1.1);
}
.btn-ghost {
background: var(--primary-dim);
color: var(--primary);
border: 1px solid hsl(4 87% 47% / 0.3);
}
.btn-ghost:hover {
background: hsl(4 87% 47% / 0.25);
}
.btn-danger {
background: transparent;
color: hsl(0 84% 60%);
padding: 6px 10px;
font-size: 12px;
border-radius: var(--radius);
}
.btn-danger:hover {
background: hsl(0 84% 60% / 0.1);
}
.btn-sm {
padding: 5px 10px;
font-size: 12px;
}
/* ── KPI Cards ─────────────────────────────────────── */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.kpi-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.kpi-card .kpi-label {
font-size: 12px;
font-weight: 500;
color: var(--fg-muted);
display: flex;
align-items: center;
gap: 6px;
}
.kpi-card .kpi-label svg {
width: 14px;
height: 14px;
}
.kpi-card .kpi-value {
font-size: 28px;
font-weight: 700;
}
/* ── Cards Grid ────────────────────────────────────── */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
transition: border-color var(--transition);
}
.card:hover {
border-color: hsl(4 87% 47% / 0.3);
}
.card h3 {
font-size: 16px;
font-weight: 700;
margin-bottom: 4px;
}
.card .desc {
font-size: 12px;
color: var(--fg-muted);
margin-bottom: 12px;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 12px;
border-top: 1px solid var(--border);
margin-top: 12px;
}
.card-footer .tag {
font-size: 12px;
background: var(--bg-hover);
padding: 3px 8px;
border-radius: var(--radius);
}
/* ── Table ─────────────────────────────────────────── */
.data-table {
width: 100%;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 12px 16px;
font-size: 12px;
font-weight: 600;
color: var(--fg-muted);
background: var(--bg-hover);
border-bottom: 1px solid var(--border);
}
.data-table td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-light);
font-size: 13px;
}
.data-table tr:last-child td {
border-bottom: none;
}
.data-table tr:hover td {
background: hsl(0 0% 50% / 0.03);
}
.status-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
}
.status-badge.aktiv {
background: hsl(142 71% 45% / 0.15);
color: var(--green);
}
.status-badge.inaktiv {
background: hsl(0 0% 50% / 0.1);
color: var(--fg-muted);
}
.color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.rep-tag {
font-size: 11px;
background: var(--bg-hover);
padding: 2px 8px;
border-radius: var(--radius);
font-family: var(--font);
}
/* ── Add Form Row ──────────────────────────────────── */
.add-form {
display: flex;
gap: 8px;
align-items: flex-end;
flex-wrap: wrap;
margin-bottom: 20px;
padding: 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.add-form .field {
display: flex;
flex-direction: column;
gap: 4px;
}
.add-form .field label {
font-size: 11px;
font-weight: 600;
color: var(--fg-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.add-form input,
.add-form select {
min-width: 140px;
}
/* ── Gantt Chart ───────────────────────────────────── */
.gantt-container {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
font-family: var(--font);
font-size: 11px;
}
.gantt-scroll-window {
position: relative;
overflow-x: auto;
border-top: 1px solid var(--border);
min-height: 400px;
}
.gantt-header {
position: sticky;
top: 0;
z-index: 20;
height: 36px;
background: hsl(0 0% 0% / 0.04);
border-bottom: 1px solid var(--border-light);
margin-left: 170px;
/* Space for labels */
}
[data-theme="light"] .gantt-header {
background: hsl(0 0% 0% / 0.02);
}
.gantt-tick {
position: absolute;
top: 0;
bottom: 0;
border-left: 1px solid var(--border-root);
border-left: 1px solid hsl(0 0% 50% / 0.2);
padding-left: 4px;
padding-top: 8px;
}
.gantt-tick-label {
font-size: 10px;
color: var(--fg-muted);
font-weight: 600;
transform: translateX(-50%);
display: inline-block;
white-space: nowrap;
}
.gantt-body {
position: relative;
min-height: 100px;
padding-bottom: 80px;
/* buffer */
}
.gantt-now-line {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background-color: var(--green);
z-index: 100;
box-shadow: 0 0 8px var(--green);
opacity: 0.8;
pointer-events: none;
}
.gantt-row-group {
margin-bottom: 8px;
}
.gantt-group-header {
background: hsl(0 0% 0% / 0.08);
font-weight: 700;
font-size: 12px;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 8px;
color: var(--fg);
border-bottom: 1px solid var(--border-light);
border-top: 1px solid var(--border-light);
}
[data-theme="light"] .gantt-group-header {
background: hsl(0 0% 0% / 0.03);
}
.gantt-row {
display: flex;
min-height: 44px;
border-bottom: 1px dashed hsl(0 0% 50% / 0.15);
background: transparent;
}
.gantt-row:hover {
background: hsl(0 0% 50% / 0.03);
}
.gantt-row-info {
width: 170px;
flex-shrink: 0;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 6px;
border-right: 1px solid var(--border-light);
background: var(--bg-card);
position: sticky;
left: 0;
z-index: 15;
}
.gantt-bars-area {
flex: 1;
position: relative;
min-height: 44px;
}
.gantt-block {
position: absolute;
top: 8px;
bottom: 8px;
border-radius: 4px;
min-width: 2px;
z-index: 10;
display: flex;
align-items: center;
overflow: hidden;
padding: 0 4px;
transition: transform 100ms ease;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.gantt-block:hover {
z-index: 50;
transform: scaleY(1.1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
filter: brightness(1.2);
}
.gantt-block-label {
font-size: 10px;
color: #fff;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
white-space: nowrap;
pointer-events: none;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Empty State ───────────────────────────────────── */
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 48px;
color: var(--fg-muted);
font-size: 13px;
border: 1px dashed var(--border);
border-radius: var(--radius-lg);
}
/* ── Loading ───────────────────────────────────────── */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 64px;
color: var(--fg-muted);
font-size: 13px;
gap: 8px;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

13
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>
)

307
src/pages/Processes.tsx Normal file
View File

@@ -0,0 +1,307 @@
import { useState, useEffect, useCallback } from 'react'
import { api, type Process, type Server } from '../api/baserow'
const REPETITIONS = [
'TAEGLICH', 'STUENDLICH', 'INTERVALL', 'WERKTAGE', 'WOCHENENDE',
'WOECHENTLICH', 'MONATLICH_FESTER_TAG', 'BENUTZERDEFINIERT'
]
const MAX_VIEWS = [
'Immer',
'Max 1 Woche',
'Max 1 Tag',
'Max 6 Stunden'
]
export default function Processes() {
const [processes, setProcesses] = useState<Process[]>([])
const [servers, setServers] = useState<Server[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
// Form state
const [editingId, setEditingId] = useState<number | null>(null)
const [name, setName] = useState('')
const [serverId, setServerId] = useState<number | ''>('')
const [status, setStatus] = useState('aktiv')
const [rep, setRep] = useState('TAEGLICH')
const [color, setColor] = useState('#3b82f6')
const [sichtbarkeit, setSichtbarkeit] = useState('Immer')
// Conditional fields
const [startTime, setStartTime] = useState('')
const [endTime, setEndTime] = useState('')
const [firstExec, setFirstExec] = useState('')
const [execUntil, setExecUntil] = useState('')
const [intervalBetween, setIntervalBetween] = useState('')
const [startMinute, setStartMinute] = useState('')
const [duration, setDuration] = useState('')
const [weekdays, setWeekdays] = useState('')
const load = useCallback(async () => {
try {
const [p, s] = await Promise.all([api.getProcesses(), api.getServers()])
setProcesses(p)
setServers(s)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const editProcess = (p: Process) => {
setEditingId(p.id)
setName(p.Name || '')
setServerId(p.Server?.[0]?.id || '')
setStatus(p.Status?.value || 'aktiv')
setRep(p.Wiederholung?.value || 'TAEGLICH')
setColor(p.Farbe || '#3b82f6')
setSichtbarkeit(p.Sichtbarkeit?.value || 'Immer')
setStartTime(p.Start || '')
setEndTime(p.Ende || '')
setFirstExec(p['Erste Ausführung'] || '')
setExecUntil(p['Ausführung bis'] || '')
setIntervalBetween(p['Intervall zwischen'] || '')
setStartMinute(p['Start Minute'] !== null && p['Start Minute'] !== undefined ? String(p['Start Minute']) : '')
setDuration(p.Dauer || '')
setWeekdays(p.Wochentage || '')
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const cancelEdit = () => {
setEditingId(null)
setName('')
setStartTime('')
setEndTime('')
setFirstExec('')
setExecUntil('')
setIntervalBetween('')
setStartMinute('')
setDuration('')
setWeekdays('')
setSichtbarkeit('Immer')
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim() || !serverId) return
setSaving(true)
const payload: any = {
Name: name.trim(),
Server: [serverId],
Status: status,
Wiederholung: rep,
Farbe: color,
Sichtbarkeit: sichtbarkeit,
Dauer: duration || null,
Wochentage: weekdays || null,
Start: startTime || null,
Ende: endTime || null,
'Start Minute': startMinute ? parseInt(startMinute) : null,
'Erste Ausführung': firstExec || null,
'Ausführung bis': execUntil || null,
'Intervall zwischen': intervalBetween || null,
}
try {
if (editingId) {
await api.updateProcess(editingId, payload)
} else {
const maxSort = processes.reduce((max, p) => Math.max(max, p.Sortierung ?? 0), 0)
payload.Sortierung = maxSort + 1
await api.createProcess(payload)
}
cancelEdit()
await load()
} catch (err: any) {
alert('Fehler beim Speichern. Hast du das Feld "Max View" in Baserow als Single-Select mit den richtigen Werten (z.B. "Immer") angelegt?\n\nDetails: ' + err.message)
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number, procName: string) => {
if (!confirm(`Prozess "${procName}" wirklich löschen?`)) return
await api.deleteProcess(id)
await load()
}
if (loading) {
return <div className="loading"><span className="spinner" /> Laden</div>
}
return (
<div>
<div className="page-header">
<div>
<h1>Prozesse</h1>
<p>Automatisierte Scripts und Backup-Tasks</p>
</div>
</div>
{/* Add/Edit form */}
<form className="add-form" onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div className="field">
<label>Name</label>
<input id="proc-name" value={name} onChange={e => setName(e.target.value)} placeholder="Prozessname" required />
</div>
<div className="field">
<label>Server</label>
<select id="proc-server" value={serverId} onChange={e => setServerId(e.target.value ? Number(e.target.value) : '')} required>
<option value="">Auswählen</option>
{servers.map(s => <option key={s.id} value={s.id}>{s.Name}</option>)}
</select>
</div>
<div className="field">
<label>Wiederholung</label>
<select id="proc-rep" value={rep} onChange={e => setRep(e.target.value)}>
{REPETITIONS.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div className="field">
<label>Status</label>
<select id="proc-status" value={status} onChange={e => setStatus(e.target.value)}>
<option value="aktiv">Aktiv</option>
<option value="inaktiv">Inaktiv</option>
</select>
</div>
<div className="field">
<label>Farbe</label>
<input type="color" value={color} onChange={e => setColor(e.target.value)} style={{ width: 40, padding: 2, height: 34 }} />
</div>
<div className="field">
<label>Max View (Sichtbarkeit)</label>
<select value={sichtbarkeit} onChange={e => setSichtbarkeit(e.target.value)}>
{MAX_VIEWS.map(v => <option key={v} value={v}>{v}</option>)}
</select>
</div>
</div>
{/* Dynamic Fields row based on chosen Wiederholung */}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', padding: '12px 0', borderTop: '1px solid var(--border)' }}>
{/* Default Start */}
{(rep === 'TAEGLICH' || rep === 'WOECHENTLICH' || rep === 'WERKTAGE' || rep === 'WOCHENENDE' || rep === 'MONATLICH_FESTER_TAG' || rep === 'BENUTZERDEFINIERT') && (
<div className="field">
<label>Startzeit (HH:MM:SS)</label>
<input value={startTime} onChange={e => setStartTime(e.target.value)} placeholder="08:00:00" style={{ width: 120 }} />
</div>
)}
{/* Stuendlich */}
{rep === 'STUENDLICH' && (
<div className="field">
<label>Start Minute</label>
<input value={startMinute} onChange={e => setStartMinute(e.target.value)} placeholder="45" style={{ width: 100 }} />
</div>
)}
{/* Intervall */}
{rep === 'INTERVALL' && (
<>
<div className="field">
<label>Erste Ausführung</label>
<input value={firstExec} onChange={e => setFirstExec(e.target.value)} placeholder="00:00:00" style={{ width: 120 }} />
</div>
<div className="field">
<label>Ausführung bis</label>
<input value={execUntil} onChange={e => setExecUntil(e.target.value)} placeholder="23:59:59" style={{ width: 120 }} />
</div>
<div className="field">
<label>Intervall (Sekunden)</label>
<input value={intervalBetween} onChange={e => setIntervalBetween(e.target.value)} placeholder="3600" style={{ width: 120 }} />
</div>
</>
)}
{/* Universal Dauer */}
<div className="field">
<label>Dauer (Sekunden)</label>
<input value={duration} onChange={e => setDuration(e.target.value)} placeholder="60" style={{ width: 120 }} />
</div>
{/* Wochentage */}
{(rep === 'WOECHENTLICH' || rep === 'INTERVALL' || rep === 'BENUTZERDEFINIERT') && (
<div className="field">
<label>Wochentage</label>
<input value={weekdays} onChange={e => setWeekdays(e.target.value)} placeholder="Mo,Di,Mi,Do,Fr" style={{ width: 140 }} />
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
{saving ? 'Speichern…' : (editingId ? '✓ Änderungen aktualisieren' : '+ Neu Hinzufügen')}
</button>
{editingId && (
<button type="button" className="btn btn-ghost btn-sm" onClick={cancelEdit}>
Abbrechen
</button>
)}
</div>
</form>
{/* Process table */}
{processes.length > 0 ? (() => {
const sortedProcesses = processes.slice().sort((a, b) => {
const sNameA = a.Server?.[0]?.value || ''
const sA = servers.find(s => s.Name === sNameA)
const sortA = sA?.Sortierung ?? 9999
const sNameB = b.Server?.[0]?.value || ''
const sB = servers.find(s => s.Name === sNameB)
const sortB = sB?.Sortierung ?? 9999
if (sortA !== sortB) return sortA - sortB
return (a.Sortierung ?? 9999) - (b.Sortierung ?? 9999)
})
return (
<table className="data-table">
<thead>
<tr>
<th>Status</th>
<th>Server</th>
<th>Prozessname</th>
<th>Wiederholung</th>
<th>Farbe</th>
<th>Sichtbarkeit</th>
<th style={{ width: 100 }}>Aktionen</th>
</tr>
</thead>
<tbody>
{sortedProcesses.map(p => (
<tr key={p.id} className={editingId === p.id ? 'editing-row' : ''}>
<td>
<span className={`status-badge ${p.Status?.value || ''}`}>
{p.Status?.value || ''}
</span>
</td>
<td style={{ color: 'var(--fg-muted)' }}>{p.Server?.[0]?.value || ''}</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<strong>{p.Name || '(Kein Name)'}</strong>
</div>
</td>
<td><span className="rep-tag">{p.Wiederholung?.value || ''}</span></td>
<td><span className="color-dot" style={{ backgroundColor: p.Farbe || '#3b82f6' }} /></td>
<td><span style={{ fontSize: '0.85em', color: 'var(--fg-muted)' }}>{p.Sichtbarkeit?.value || 'Immer'}</span></td>
<td style={{ textAlign: 'right', display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn" style={{ padding: '4px 8px' }} onClick={() => editProcess(p)}>Edit</button>
<button className="btn-danger" style={{ padding: '4px 8px' }} onClick={() => handleDelete(p.id, p.Name)}>Löschen</button>
</td>
</tr>
))}
</tbody>
</table>
)
})() : (
<div className="empty-state">Noch keine Prozesse angelegt.</div>
)}
</div>
)
}

105
src/pages/Servers.tsx Normal file
View File

@@ -0,0 +1,105 @@
import { useState, useEffect, useCallback } from 'react'
import { api, type Server } from '../api/baserow'
export default function Servers() {
const [servers, setServers] = useState<Server[]>([])
const [loading, setLoading] = useState(true)
const [name, setName] = useState('')
const [desc, setDesc] = useState('')
const [saving, setSaving] = useState(false)
const load = useCallback(async () => {
try {
setServers(await api.getServers())
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
setSaving(true)
try {
await api.createServer(name.trim(), desc.trim())
setName('')
setDesc('')
await load()
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number, serverName: string) => {
if (!confirm(`Server "${serverName}" wirklich löschen?`)) return
await api.deleteServer(id)
await load()
}
if (loading) {
return <div className="loading"><span className="spinner" /> Laden</div>
}
return (
<div>
<div className="page-header">
<div>
<h1>Server</h1>
<p>Server verwalten und überwachen</p>
</div>
</div>
{/* Add form */}
<form className="add-form" onSubmit={handleAdd}>
<div className="field">
<label>Name</label>
<input
id="server-name"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Server-Name"
required
/>
</div>
<div className="field">
<label>Beschreibung</label>
<input
id="server-desc"
value={desc}
onChange={e => setDesc(e.target.value)}
placeholder="Optional"
/>
</div>
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
{saving ? 'Speichern…' : '+ Hinzufügen'}
</button>
</form>
{/* Server cards */}
<div className="cards-grid">
{servers.map(s => (
<div key={s.id} className="card">
<h3>{s.Name || '(Kein Name)'}</h3>
{s.Beschreibung && <div className="desc">{s.Beschreibung}</div>}
<div className="card-footer">
<span className="tag">{s.Prozesse?.length || 0} Prozesse</span>
<button
className="btn-danger"
onClick={() => handleDelete(s.id, s.Name)}
>
Löschen
</button>
</div>
</div>
))}
{servers.length === 0 && (
<div className="empty-state">
Noch keine Server konfiguriert. Füge einen hinzu!
</div>
)}
</div>
</div>
)
}

84
src/pages/Timeline.tsx Normal file
View File

@@ -0,0 +1,84 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { api, type Server, type Process } from '../api/baserow'
import GanttChart from '../components/GanttChart'
export default function Timeline() {
const [servers, setServers] = useState<Server[]>([])
const [processes, setProcesses] = useState<Process[]>([])
const [loading, setLoading] = useState(true)
const [runningCount, setRunningCount] = useState(0)
const load = useCallback(async () => {
try {
const [s, p] = await Promise.all([api.getServers(), api.getProcesses()])
setServers(s)
setProcesses(p)
} catch (e) {
console.error('Failed to load data:', e)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
const interval = setInterval(load, 30_000) // Auto-refresh every 30s
return () => clearInterval(interval)
}, [load])
const activeCount = processes.filter(p => p.Status?.value === 'aktiv').length
if (loading) {
return <div className="loading"><span className="spinner" /> Daten werden geladen</div>
}
return (
<div>
<div className="page-header">
<div>
<h1>Prozess-Zeitplan</h1>
<p>24-Stunden Übersicht aller Server-Prozesse</p>
</div>
<Link to="/servers" className="btn btn-ghost">+ Server verwalten</Link>
</div>
{/* KPI Cards */}
<div className="kpi-grid">
<div className="kpi-card">
<div className="kpi-label">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ color: 'var(--blue)' }}><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>
Server
</div>
<div className="kpi-value">{servers.length}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ color: 'var(--green)' }}><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>
Prozesse gesamt
</div>
<div className="kpi-value">{processes.length}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ color: 'var(--yellow)' }}><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Aktive Prozesse
</div>
<div className="kpi-value">{activeCount}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ color: 'hsl(174 60% 51%)' }}><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
Laufen gerade
</div>
<div className="kpi-value" id="kpi-running">{runningCount}</div>
</div>
</div>
<GanttChart processes={processes} servers={servers} onRunningChange={setRunningCount} />
</div>
)
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

5
start.bat Normal file
View File

@@ -0,0 +1,5 @@
@echo off
echo Starting AZion Scheduler...
set "PATH=%LOCALAPPDATA%\nodejs-portable\node-v22.15.0-win-x64;%PATH%"
npm run dev
pause

10
test-browser.js Normal file
View File

@@ -0,0 +1,10 @@
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
page.on('console', msg => console.log('PAGE LOG:', msg.text()));
page.on('pageerror', error => console.log('PAGE ERROR:', error.message));
page.on('requestfailed', request => console.log('REQUEST FAILED:', request.url(), request.failure().errorText));
await page.goto('http://localhost:3001', {waitUntil: 'networkidle0'}).catch(e => console.log('GOTO ERROR:', e.message));
await browser.close();
})();

9
test-time.js Normal file
View File

@@ -0,0 +1,9 @@
function parseTime(tStr) {
if (!tStr) return [12, 0]
const p = tStr.toString().split(':').map(Number)
return [isNaN(p[0]) ? 12 : p[0], isNaN(p[1]) ? 0 : p[1]]
}
console.log(parseTime('00:00:30'));
console.log(parseTime('07:23'));
console.log(parseTime(''));

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}

15
vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3001,
watch: {
usePolling: true
}
},
resolve: {
preserveSymlinks: true
}
})