first commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Schedule Task Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"cronstrue": "^2.53.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Routes, Route, NavLink } from "react-router-dom";
|
||||
import { api, type CurrentUser, UnauthorizedError } from "./api/client";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import ServersAdmin from "./pages/ServersAdmin";
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState<CurrentUser | null | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
api.me().then(
|
||||
(res) => setUser(res.user),
|
||||
(err) => {
|
||||
if (!(err instanceof UnauthorizedError)) {
|
||||
console.error(err);
|
||||
}
|
||||
setUser(null);
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (user === undefined) {
|
||||
return <div className="loading-screen">Loading…</div>;
|
||||
}
|
||||
|
||||
if (user === null) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
<div className="brand">Schedule Task Manager</div>
|
||||
<nav>
|
||||
<NavLink to="/" end>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink to="/servers">Servers</NavLink>
|
||||
</nav>
|
||||
<div className="user-info">
|
||||
<span>{user.name ?? user.email ?? user.sub}</span>
|
||||
<a href="/auth/logout">Sign out</a>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/servers" element={<ServersAdmin />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export interface ServerRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
osType: string;
|
||||
description: string | null;
|
||||
apiTokenPrefix: string;
|
||||
createdAt: string;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
export interface TaskRecord {
|
||||
id: number;
|
||||
serverId: number;
|
||||
serverName: string;
|
||||
scheduleType: "cron" | "systemd_timer";
|
||||
name: string;
|
||||
command: string | null;
|
||||
scheduleExpression: string | null;
|
||||
source: string | null;
|
||||
enabled: boolean;
|
||||
nextRunAt: string | null;
|
||||
isStale: boolean;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
throw new UnauthorizedError("unauthorized");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Request failed (${res.status}): ${body}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request<{ user: CurrentUser }>("/api/me"),
|
||||
servers: {
|
||||
list: () => request<{ servers: ServerRecord[] }>("/api/servers"),
|
||||
create: (data: { name: string; hostname?: string; description?: string }) =>
|
||||
request<{ server: ServerRecord; token: string }>("/api/servers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
rotateToken: (id: number) =>
|
||||
request<{ server: ServerRecord; token: string }>(`/api/servers/${id}/rotate-token`, {
|
||||
method: "POST",
|
||||
}),
|
||||
remove: (id: number) => request<void>(`/api/servers/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
tasks: {
|
||||
list: (
|
||||
params: { serverId?: number; scheduleType?: string; search?: string; includeStale?: boolean } = {},
|
||||
) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.serverId) qs.set("serverId", String(params.serverId));
|
||||
if (params.scheduleType) qs.set("scheduleType", params.scheduleType);
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.includeStale) qs.set("includeStale", "true");
|
||||
const query = qs.toString();
|
||||
return request<{ tasks: TaskRecord[] }>(`/api/tasks${query ? `?${query}` : ""}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ServerRecord } from "../api/client";
|
||||
|
||||
export interface Filters {
|
||||
serverId: number | undefined;
|
||||
scheduleType: string | undefined;
|
||||
search: string;
|
||||
includeStale: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
servers: ServerRecord[];
|
||||
filters: Filters;
|
||||
onChange: (filters: Filters) => void;
|
||||
}
|
||||
|
||||
export default function FilterBar({ servers, filters, onChange }: Props) {
|
||||
return (
|
||||
<div className="filter-bar">
|
||||
<select
|
||||
value={filters.serverId ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...filters, serverId: e.target.value ? Number(e.target.value) : undefined })
|
||||
}
|
||||
>
|
||||
<option value="">All servers</option>
|
||||
{servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filters.scheduleType ?? ""}
|
||||
onChange={(e) => onChange({ ...filters, scheduleType: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">All schedule types</option>
|
||||
<option value="cron">Cron</option>
|
||||
<option value="systemd_timer">systemd timer</option>
|
||||
</select>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search name or command…"
|
||||
value={filters.search}
|
||||
onChange={(e) => onChange({ ...filters, search: e.target.value })}
|
||||
/>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.includeStale}
|
||||
onChange={(e) => onChange({ ...filters, includeStale: e.target.checked })}
|
||||
/>
|
||||
Show stale/missing tasks
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { TaskRecord } from "../api/client";
|
||||
import TaskTable from "./TaskTable";
|
||||
|
||||
const SCHEDULE_TYPE_LABELS: Record<string, string> = {
|
||||
cron: "Cron jobs",
|
||||
systemd_timer: "systemd timers",
|
||||
};
|
||||
|
||||
export default function ServerGroup({ serverName, tasks }: { serverName: string; tasks: TaskRecord[] }) {
|
||||
const byType = new Map<string, TaskRecord[]>();
|
||||
for (const task of tasks) {
|
||||
const list = byType.get(task.scheduleType) ?? [];
|
||||
list.push(task);
|
||||
byType.set(task.scheduleType, list);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="server-group">
|
||||
<h2>{serverName}</h2>
|
||||
{[...byType.entries()].map(([scheduleType, groupTasks]) => (
|
||||
<div key={scheduleType} className="schedule-type-group">
|
||||
<h3>{SCHEDULE_TYPE_LABELS[scheduleType] ?? scheduleType}</h3>
|
||||
<TaskTable tasks={groupTasks} />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import cronstrue from "cronstrue";
|
||||
import type { TaskRecord } from "../api/client";
|
||||
|
||||
function describeSchedule(task: TaskRecord): string {
|
||||
if (!task.scheduleExpression) return "—";
|
||||
if (task.scheduleType === "cron") {
|
||||
try {
|
||||
return cronstrue.toString(task.scheduleExpression, { verbose: false });
|
||||
} catch {
|
||||
return task.scheduleExpression;
|
||||
}
|
||||
}
|
||||
return task.scheduleExpression;
|
||||
}
|
||||
|
||||
export default function TaskTable({ tasks }: { tasks: TaskRecord[] }) {
|
||||
return (
|
||||
<table className="task-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Command</th>
|
||||
<th>Schedule</th>
|
||||
<th>Next run</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => (
|
||||
<tr key={task.id} className={task.isStale ? "stale" : undefined}>
|
||||
<td>{task.name}</td>
|
||||
<td className="mono">{task.command ?? "—"}</td>
|
||||
<td>
|
||||
<div className="schedule-human">{describeSchedule(task)}</div>
|
||||
{task.scheduleExpression && <span className="mono schedule-raw">{task.scheduleExpression}</span>}
|
||||
</td>
|
||||
<td>{task.nextRunAt ? new Date(task.nextRunAt).toLocaleString() : "—"}</td>
|
||||
<td>
|
||||
{task.isStale ? (
|
||||
<span className="badge badge-stale">Missing</span>
|
||||
) : task.enabled ? (
|
||||
<span className="badge badge-ok">Enabled</span>
|
||||
) : (
|
||||
<span className="badge badge-disabled">Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -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 "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type ServerRecord, type TaskRecord } from "../api/client";
|
||||
import FilterBar, { type Filters } from "../components/FilterBar";
|
||||
import ServerGroup from "../components/ServerGroup";
|
||||
|
||||
export default function Dashboard() {
|
||||
const [servers, setServers] = useState<ServerRecord[]>([]);
|
||||
const [tasks, setTasks] = useState<TaskRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
serverId: undefined,
|
||||
scheduleType: undefined,
|
||||
search: "",
|
||||
includeStale: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
api.servers
|
||||
.list()
|
||||
.then((res) => setServers(res.servers))
|
||||
.catch((err) => setError(String(err)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const handle = setTimeout(() => {
|
||||
api.tasks
|
||||
.list(filters)
|
||||
.then((res) => {
|
||||
setTasks(res.tasks);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => setError(String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [filters]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const byServer = new Map<number, { serverId: number; serverName: string; tasks: TaskRecord[] }>();
|
||||
for (const task of tasks) {
|
||||
const entry = byServer.get(task.serverId) ?? {
|
||||
serverId: task.serverId,
|
||||
serverName: task.serverName,
|
||||
tasks: [],
|
||||
};
|
||||
entry.tasks.push(task);
|
||||
byServer.set(task.serverId, entry);
|
||||
}
|
||||
return [...byServer.values()];
|
||||
}, [tasks]);
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<FilterBar servers={servers} filters={filters} onChange={setFilters} />
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{loading && <div className="loading-inline">Loading tasks…</div>}
|
||||
{!loading && groups.length === 0 && (
|
||||
<div className="empty-state">
|
||||
No scheduled tasks found yet. Add a server under "Servers" and install the agent to start reporting.
|
||||
</div>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<ServerGroup key={group.serverId} serverName={group.serverName} tasks={group.tasks} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export default function Login() {
|
||||
return (
|
||||
<div className="login-screen">
|
||||
<div className="login-card">
|
||||
<h1>Schedule Task Manager</h1>
|
||||
<p>Sign in with your homelab identity provider to continue.</p>
|
||||
<a className="btn btn-primary" href="/auth/login">
|
||||
Sign in with Authentik
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { api, type ServerRecord } from "../api/client";
|
||||
|
||||
export default function ServersAdmin() {
|
||||
const [servers, setServers] = useState<ServerRecord[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [hostname, setHostname] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [newToken, setNewToken] = useState<{ server: ServerRecord; token: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = () => api.servers.list().then((res) => setServers(res.servers));
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((err) => setError(String(err)));
|
||||
}, []);
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.servers.create({
|
||||
name,
|
||||
hostname: hostname || undefined,
|
||||
description: description || undefined,
|
||||
});
|
||||
setNewToken(result);
|
||||
setName("");
|
||||
setHostname("");
|
||||
setDescription("");
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRotate(id: number) {
|
||||
try {
|
||||
const result = await api.servers.rotateToken(id);
|
||||
setNewToken(result);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!confirm("Remove this server and all its tracked tasks?")) return;
|
||||
try {
|
||||
await api.servers.remove(id);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="servers-admin">
|
||||
<section className="add-server">
|
||||
<h2>Add a server</h2>
|
||||
<form onSubmit={handleCreate}>
|
||||
<input
|
||||
placeholder="Server name (e.g. nas01)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
placeholder="Hostname (optional)"
|
||||
value={hostname}
|
||||
onChange={(e) => setHostname(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
placeholder="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Add server
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{newToken && (
|
||||
<section className="token-reveal">
|
||||
<h3>Agent token for {newToken.server.name}</h3>
|
||||
<p>This token is only shown once — copy it now.</p>
|
||||
<code className="token-value">{newToken.token}</code>
|
||||
<p>Install the agent on the server:</p>
|
||||
<pre className="install-command">{`curl -fsSL ${window.location.origin}/agent/linux/install.sh | API_URL=${window.location.origin} API_TOKEN=${newToken.token} bash`}</pre>
|
||||
<button onClick={() => setNewToken(null)} className="btn">
|
||||
Dismiss
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="server-list">
|
||||
<h2>Servers</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Hostname</th>
|
||||
<th>Token</th>
|
||||
<th>Last seen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{servers.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.name}</td>
|
||||
<td>{s.hostname ?? "—"}</td>
|
||||
<td className="mono">{s.apiTokenPrefix}…</td>
|
||||
<td>{s.lastSeenAt ? new Date(s.lastSeenAt).toLocaleString() : "never"}</td>
|
||||
<td className="actions">
|
||||
<button onClick={() => handleRotate(s.id)} className="btn btn-small">
|
||||
Rotate token
|
||||
</button>
|
||||
<button onClick={() => handleDelete(s.id)} className="btn btn-small btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f5f6f8;
|
||||
--surface: #ffffff;
|
||||
--border: #dde1e6;
|
||||
--text: #1c2126;
|
||||
--text-muted: #5b6470;
|
||||
--accent: #2f6fed;
|
||||
--accent-contrast: #ffffff;
|
||||
--danger: #d64545;
|
||||
--stale-bg: #fff6e5;
|
||||
--ok-bg: #e5f6ea;
|
||||
--ok-text: #1f8a3d;
|
||||
--disabled-bg: #eceff2;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #14171c;
|
||||
--surface: #1c2128;
|
||||
--border: #2c333c;
|
||||
--text: #e6e9ed;
|
||||
--text-muted: #9aa4b1;
|
||||
--accent: #5b8dfc;
|
||||
--accent-contrast: #0b1220;
|
||||
--stale-bg: #332a12;
|
||||
--ok-bg: #123321;
|
||||
--ok-text: #4fd07a;
|
||||
--disabled-bg: #232830;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.loading-screen,
|
||||
.login-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem;
|
||||
max-width: 380px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
margin-top: 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.app-header .brand {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-header nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-header nav a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.app-header nav a.active,
|
||||
.app-header nav a:hover {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.user-info a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-bar select,
|
||||
.filter-bar input[type="search"] {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.server-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.server-group h2 {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.schedule-type-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.schedule-type-group h3 {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tr.stale {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.schedule-human {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.schedule-raw {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
background: var(--ok-bg);
|
||||
color: var(--ok-text);
|
||||
}
|
||||
|
||||
.badge-disabled {
|
||||
background: var(--disabled-bg);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badge-stale {
|
||||
background: var(--stale-bg);
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.loading-inline {
|
||||
color: var(--text-muted);
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: #fdecec;
|
||||
color: var(--danger);
|
||||
border: 1px solid #f3b8b8;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.add-server form {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.add-server input {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.token-reveal {
|
||||
background: var(--ok-bg);
|
||||
border: 1px solid var(--ok-text);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
display: block;
|
||||
background: var(--surface);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin: 0.5rem 0;
|
||||
word-break: break-all;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.install-command {
|
||||
background: var(--surface);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.servers-admin section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/filterbar.tsx","./src/components/servergroup.tsx","./src/components/tasktable.tsx","./src/pages/dashboard.tsx","./src/pages/login.tsx","./src/pages/serversadmin.tsx"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:3000",
|
||||
"/auth": "http://localhost:3000",
|
||||
"/health": "http://localhost:3000",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user