Initial commit
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.local
|
||||
79
README.md
79
README.md
@@ -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 0–59).*
|
||||
* `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
26
check-ui.js
Normal 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
13
index.html
Normal 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>
|
||||
1771
package-lock.json
generated
Normal file
1771
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal 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
1
public/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Empty Gitkeep File
|
||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
37
src/App.tsx
Normal file
37
src/App.tsx
Normal 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
104
src/api/baserow.ts
Normal 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),
|
||||
};
|
||||
457
src/components/GanttChart.tsx
Normal file
457
src/components/GanttChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
src/components/Sidebar.tsx
Normal file
76
src/components/Sidebar.tsx
Normal 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
783
src/index.css
Normal 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
13
src/main.tsx
Normal 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
307
src/pages/Processes.tsx
Normal 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
105
src/pages/Servers.tsx
Normal 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
84
src/pages/Timeline.tsx
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
5
start.bat
Normal file
5
start.bat
Normal 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
10
test-browser.js
Normal 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
9
test-time.js
Normal 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
21
tsconfig.json
Normal 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
15
vite.config.ts
Normal 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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user