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