mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 08:36:55 +09:00
feat(jobs): admin Jobs UI page over the unified job API (Phase 9)
Add /jobs — the single pane to observe and manage all background work, built on the /api/v1/jobs API. Mirrors the DeliveryLog/Telemetry admin pages. - Jobs table polls every 5s: status Pills (succeeded/running/pending/failed/ cancelled), kind, owner, attempts, age. Status/kind filters. Per-row Cancel (pending/running) and Retry (failed/cancelled) with toast feedback. Row expands to a detail view (payload/result/error/correlation/timing) via getJob. - Scheduled-jobs table: name, kind, schedule (cron or "every Nh"), next/last run, with Run-now and Pause/Enable. - Stat cards (total/running/pending/succeeded/failed) from /jobs/stats. - Wiring: services/api/jobs.js module bound into ApiService; route + page title in App.jsx; "Jobs" item in the sidebar "system" group; _jobs.scss imported in main.scss. Admin-gated client-side via useAuth().isAdmin. Lint clean (0 errors) and production build passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c0cb0f088e
commit
70f8697586
7 files changed
+589
No files matched your search
@@ -74,6 +74,7 @@ import QueueOperations from './pages/QueueOperations';
|
||||
import Notifications from './pages/Notifications';
|
||||
import DeliveryLog from './pages/DeliveryLog';
|
||||
import Telemetry from './pages/Telemetry';
|
||||
import Jobs from './pages/Jobs';
|
||||
import useExtensionRoutes from './plugins/ExtensionRoutes';
|
||||
import { useContributions } from './plugins/contributions';
|
||||
|
||||
@@ -128,6 +129,7 @@ const PAGE_TITLES = {
|
||||
'/notifications': 'Notifications',
|
||||
'/admin/notifications': 'Notification Delivery Log',
|
||||
'/telemetry': 'Telemetry',
|
||||
'/jobs': 'Jobs',
|
||||
};
|
||||
|
||||
function PageTitleUpdater() {
|
||||
@@ -367,6 +369,7 @@ function AppRoutes() {
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
<Route path="admin/notifications" element={<DeliveryLog />} />
|
||||
<Route path="telemetry" element={<Telemetry />} />
|
||||
<Route path="jobs" element={<Jobs />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="settings/:tab" element={<Settings />} />
|
||||
{dashboardRoutes}
|
||||
|
||||
@@ -186,6 +186,13 @@ export const SIDEBAR_ITEMS = [
|
||||
category: 'system',
|
||||
icon: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>'
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'Jobs',
|
||||
route: '/jobs',
|
||||
category: 'system',
|
||||
icon: '<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>'
|
||||
},
|
||||
{
|
||||
id: 'gpu',
|
||||
label: 'GPU Monitor',
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Layers, RefreshCw, Inbox, Play, X, RotateCcw, ChevronRight, Clock,
|
||||
} from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { PageTopbar, MetricCard, Pill } from '@/components/ds';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { timeAgo } from '../utils/timeAgo';
|
||||
|
||||
const STATUSES = ['all', 'pending', 'running', 'succeeded', 'failed', 'cancelled'];
|
||||
const POLL_MS = 5000;
|
||||
|
||||
// Job status -> Pill colour.
|
||||
const PILL_KIND = {
|
||||
succeeded: 'green',
|
||||
running: 'cyan',
|
||||
pending: 'amber',
|
||||
failed: 'red',
|
||||
cancelled: 'gray',
|
||||
};
|
||||
|
||||
function StatusPill({ status }) {
|
||||
return <Pill kind={PILL_KIND[status] || 'gray'}>{status}</Pill>;
|
||||
}
|
||||
|
||||
function describeSchedule(s) {
|
||||
if (s.schedule_kind === 'cron' && s.cron) return s.cron;
|
||||
const sec = s.interval_seconds;
|
||||
if (!sec) return '—';
|
||||
if (sec % 86400 === 0) return `every ${sec / 86400}d`;
|
||||
if (sec % 3600 === 0) return `every ${sec / 3600}h`;
|
||||
if (sec % 60 === 0) return `every ${sec / 60}m`;
|
||||
return `every ${sec}s`;
|
||||
}
|
||||
|
||||
function JobDetail({ detail }) {
|
||||
if (!detail) return <div className="jobs-detail jobs-detail--loading">Loading details…</div>;
|
||||
const payload = detail.payload && Object.keys(detail.payload).length > 0 ? detail.payload : null;
|
||||
return (
|
||||
<div className="jobs-detail">
|
||||
<div className="jobs-detail__grid">
|
||||
<span>ID</span><code>{detail.id}</code>
|
||||
<span>Correlation</span><code>{detail.correlation_id || '—'}</code>
|
||||
<span>Started</span><span>{detail.started_at ? new Date(detail.started_at).toLocaleString() : '—'}</span>
|
||||
<span>Completed</span><span>{detail.completed_at ? new Date(detail.completed_at).toLocaleString() : '—'}</span>
|
||||
<span>Duration</span><span>{detail.duration != null ? `${detail.duration.toFixed(2)}s` : '—'}</span>
|
||||
</div>
|
||||
{detail.error_message && (
|
||||
<div className="jobs-detail__block jobs-detail__block--error">
|
||||
<span>Error</span>
|
||||
<pre>{detail.error_message}</pre>
|
||||
</div>
|
||||
)}
|
||||
{payload && (
|
||||
<div className="jobs-detail__block">
|
||||
<span>Payload</span>
|
||||
<pre>{JSON.stringify(payload, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
{detail.result != null && (
|
||||
<div className="jobs-detail__block">
|
||||
<span>Result</span>
|
||||
<pre>{JSON.stringify(detail.result, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobRow({ job, expanded, detail, onToggle, onCancel, onRetry }) {
|
||||
const canCancel = job.status === 'pending' || job.status === 'running';
|
||||
const canRetry = job.status === 'failed' || job.status === 'cancelled';
|
||||
return (
|
||||
<>
|
||||
<tr className={`jobs-row${expanded ? ' is-open' : ''}`} onClick={onToggle}>
|
||||
<td className="jobs-caret">
|
||||
<ChevronRight size={14} className={expanded ? 'is-open' : ''} />
|
||||
</td>
|
||||
<td><StatusPill status={job.status} /></td>
|
||||
<td className="jobs-mono">{job.kind}</td>
|
||||
<td className="jobs-owner">
|
||||
{job.owner_type ? `${job.owner_type}${job.owner_id ? `:${job.owner_id}` : ''}` : '—'}
|
||||
</td>
|
||||
<td>{job.attempts}/{job.max_attempts}</td>
|
||||
<td className="jobs-when">{timeAgo(job.created_at)}</td>
|
||||
<td className="jobs-actions" onClick={(e) => e.stopPropagation()}>
|
||||
{canCancel && (
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}><X size={14} /> Cancel</Button>
|
||||
)}
|
||||
{canRetry && (
|
||||
<Button variant="ghost" size="sm" onClick={onRetry}><RotateCcw size={14} /> Retry</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="jobs-detail-row">
|
||||
<td colSpan={7}><JobDetail detail={detail} /></td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Jobs() {
|
||||
const { isAdmin } = useAuth();
|
||||
const toast = useToast();
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [kinds, setKinds] = useState([]);
|
||||
const [scheduled, setScheduled] = useState([]);
|
||||
const [status, setStatus] = useState('all');
|
||||
const [kind, setKind] = useState('all');
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pollRef = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const params = {};
|
||||
if (status !== 'all') params.status = status;
|
||||
if (kind !== 'all') params.kind = kind;
|
||||
const [jobsData, statsData, schedData] = await Promise.all([
|
||||
api.getJobs(params),
|
||||
api.getJobStats(),
|
||||
api.getScheduledJobs(),
|
||||
]);
|
||||
setJobs(jobsData.jobs || []);
|
||||
setStats(statsData || null);
|
||||
setScheduled(schedData.scheduled || []);
|
||||
} catch {
|
||||
// leave the last good state on screen
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [status, kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return undefined;
|
||||
load();
|
||||
pollRef.current = setInterval(load, POLL_MS);
|
||||
return () => clearInterval(pollRef.current);
|
||||
}, [isAdmin, load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
api.getJobKinds().then((d) => setKinds(d.kinds || [])).catch(() => {});
|
||||
}, [isAdmin]);
|
||||
|
||||
const toggleDetail = async (job) => {
|
||||
if (expandedId === job.id) {
|
||||
setExpandedId(null);
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
setExpandedId(job.id);
|
||||
setDetail(null);
|
||||
try {
|
||||
const data = await api.getJob(job.id);
|
||||
setDetail(data.job || null);
|
||||
} catch {
|
||||
setDetail(null);
|
||||
}
|
||||
};
|
||||
|
||||
const action = async (fn, okMsg, failMsg) => {
|
||||
try {
|
||||
await fn();
|
||||
toast.success(okMsg);
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error(e?.message || failMsg);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<>
|
||||
<PageTopbar icon={<Layers size={18} />} title="Jobs" />
|
||||
<div className="jobs-page"><div className="jobs-empty">Admins only.</div></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const byStatus = stats?.by_status || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTopbar
|
||||
icon={<Layers size={18} />}
|
||||
title="Jobs"
|
||||
meta="Background work across the Queue Bus"
|
||||
actions={(
|
||||
<Button variant="outline" size="sm" onClick={load}>
|
||||
<RefreshCw size={14} /> Refresh
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="jobs-page">
|
||||
<div className="jobs-stats">
|
||||
<MetricCard label="Total" value={stats?.total ?? 0} tone="accent" />
|
||||
<MetricCard label="Running" value={byStatus.running ?? 0} />
|
||||
<MetricCard label="Pending" value={byStatus.pending ?? 0} tone="amber" />
|
||||
<MetricCard label="Succeeded" value={byStatus.succeeded ?? 0} tone="green" />
|
||||
<MetricCard label="Failed" value={byStatus.failed ?? 0} tone="red" />
|
||||
</div>
|
||||
|
||||
<div className="jobs-filters">
|
||||
<label>
|
||||
Status
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Kind
|
||||
<select value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||
<option value="all">all</option>
|
||||
{kinds.map((k) => <option key={k} value={k}>{k}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{loading && jobs.length === 0 ? (
|
||||
<div className="jobs-empty">Loading…</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="jobs-empty">
|
||||
<Inbox size={24} aria-hidden="true" />
|
||||
<p>No jobs match these filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="jobs-table-wrap">
|
||||
<table className="jobs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th aria-label="expand" />
|
||||
<th>Status</th>
|
||||
<th>Kind</th>
|
||||
<th>Owner</th>
|
||||
<th>Tries</th>
|
||||
<th>Created</th>
|
||||
<th aria-label="Actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((j) => (
|
||||
<JobRow
|
||||
key={j.id}
|
||||
job={j}
|
||||
expanded={expandedId === j.id}
|
||||
detail={expandedId === j.id ? detail : null}
|
||||
onToggle={() => toggleDetail(j)}
|
||||
onCancel={() => action(() => api.cancelJob(j.id), 'Job cancelled', 'Cancel failed')}
|
||||
onRetry={() => action(() => api.retryJob(j.id), 'Job re-queued', 'Retry failed')}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="jobs-section-head">
|
||||
<Clock size={16} aria-hidden="true" />
|
||||
<h2>Scheduled jobs</h2>
|
||||
</div>
|
||||
|
||||
{scheduled.length === 0 ? (
|
||||
<div className="jobs-empty"><p>No scheduled jobs.</p></div>
|
||||
) : (
|
||||
<div className="jobs-table-wrap">
|
||||
<table className="jobs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Kind</th>
|
||||
<th>Schedule</th>
|
||||
<th>Next run</th>
|
||||
<th>Last run</th>
|
||||
<th aria-label="Actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scheduled.map((s) => (
|
||||
<tr key={s.id} className={s.enabled ? '' : 'is-disabled'}>
|
||||
<td className="jobs-mono">{s.name}</td>
|
||||
<td className="jobs-mono">{s.kind}</td>
|
||||
<td>{describeSchedule(s)}</td>
|
||||
<td>
|
||||
{s.enabled
|
||||
? (s.next_run_at ? timeAgo(s.next_run_at) : '—')
|
||||
: <Pill kind="gray">paused</Pill>}
|
||||
</td>
|
||||
<td className="jobs-when">{s.last_run_at ? timeAgo(s.last_run_at) : '—'}</td>
|
||||
<td className="jobs-actions">
|
||||
<Button variant="ghost" size="sm" title="Run now"
|
||||
onClick={() => action(() => api.runScheduledJob(s.id), `Enqueued "${s.name}"`, 'Run failed')}>
|
||||
<Play size={14} /> Run
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => action(
|
||||
() => api.setScheduledJobEnabled(s.id, !s.enabled),
|
||||
s.enabled ? `Paused "${s.name}"` : `Enabled "${s.name}"`,
|
||||
'Update failed',
|
||||
)}>
|
||||
{s.enabled ? 'Pause' : 'Enable'}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import * as containerOpsMethods from './containerOps.js';
|
||||
import * as queueBusMethods from './queueBus.js';
|
||||
import * as notificationMethods from './notifications.js';
|
||||
import * as telemetryMethods from './telemetry.js';
|
||||
import * as jobMethods from './jobs.js';
|
||||
|
||||
class ApiService extends ApiClient {
|
||||
constructor() {
|
||||
@@ -49,6 +50,7 @@ class ApiService extends ApiClient {
|
||||
queueBusMethods,
|
||||
notificationMethods,
|
||||
telemetryMethods,
|
||||
jobMethods,
|
||||
];
|
||||
for (const mod of modules) {
|
||||
for (const [key, fn] of Object.entries(mod)) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Unified job system API (admin only) — backs the Jobs page.
|
||||
|
||||
export async function getJobs(params = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.status) query.append('status', params.status);
|
||||
if (params.kind) query.append('kind', params.kind);
|
||||
if (params.owner_type) query.append('owner_type', params.owner_type);
|
||||
if (params.owner_id) query.append('owner_id', params.owner_id);
|
||||
if (params.limit) query.append('limit', params.limit);
|
||||
if (params.offset) query.append('offset', params.offset);
|
||||
const suffix = query.toString() ? `?${query}` : '';
|
||||
return this.request(`/jobs${suffix}`);
|
||||
}
|
||||
|
||||
export async function getJobStats() {
|
||||
return this.request('/jobs/stats');
|
||||
}
|
||||
|
||||
export async function getJobKinds() {
|
||||
return this.request('/jobs/kinds');
|
||||
}
|
||||
|
||||
export async function getJob(jobId) {
|
||||
return this.request(`/jobs/${jobId}`);
|
||||
}
|
||||
|
||||
export async function cancelJob(jobId) {
|
||||
return this.request(`/jobs/${jobId}/cancel`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function retryJob(jobId) {
|
||||
return this.request(`/jobs/${jobId}/retry`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function getScheduledJobs() {
|
||||
return this.request('/jobs/scheduled');
|
||||
}
|
||||
|
||||
export async function runScheduledJob(scheduledId) {
|
||||
return this.request(`/jobs/scheduled/${scheduledId}/run`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function setScheduledJobEnabled(scheduledId, enabled) {
|
||||
return this.request(`/jobs/scheduled/${scheduledId}/enabled`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
}
|
||||
@@ -120,3 +120,4 @@
|
||||
@import 'pages/_dynamic-dns';
|
||||
@import 'pages/_queue-operations';
|
||||
@import 'pages/_telemetry';
|
||||
@import 'pages/_jobs';
|
||||
@@ -0,0 +1,208 @@
|
||||
.jobs-page {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.jobs-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.jobs-filters {
|
||||
display: flex;
|
||||
gap: $spacing-lg;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: $spacing-sm;
|
||||
border-radius: $radius-sm;
|
||||
border: 1px solid $border-default;
|
||||
background: $bg-card;
|
||||
color: $text-primary;
|
||||
font-size: 0.875rem;
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.jobs-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid $border-default;
|
||||
border-radius: $radius-md;
|
||||
background: $bg-card;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.jobs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: $text-tertiary;
|
||||
border-bottom: 1px solid $border-default;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-bottom: 1px solid $border-default;
|
||||
color: $text-primary;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tr.is-disabled td {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
.jobs-row {
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover,
|
||||
&.is-open {
|
||||
background: $bg-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.jobs-caret svg {
|
||||
color: $text-tertiary;
|
||||
transition: transform $transition-fast;
|
||||
|
||||
&.is-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.jobs-mono {
|
||||
font-family: var(--mono, monospace);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.jobs-owner {
|
||||
color: $text-tertiary;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.jobs-when {
|
||||
color: $text-tertiary;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jobs-actions {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
justify-content: flex-end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jobs-detail-row > td {
|
||||
padding: 0;
|
||||
background: $bg-tertiary;
|
||||
}
|
||||
|
||||
.jobs-detail {
|
||||
padding: $spacing-md $spacing-lg;
|
||||
|
||||
&--loading {
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: $spacing-xs $spacing-md;
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
span:nth-child(odd) {
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--mono, monospace);
|
||||
color: $text-primary;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
&__block {
|
||||
margin-top: $spacing-sm;
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: $text-tertiary;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
background: $bg-card;
|
||||
border: 1px solid $border-default;
|
||||
border-radius: $radius-sm;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8125rem;
|
||||
color: $text-primary;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&--error pre {
|
||||
color: $danger;
|
||||
border-color: $danger;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.jobs-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
margin: $spacing-lg 0 $spacing-md;
|
||||
color: $text-primary;
|
||||
|
||||
svg {
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.jobs-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-xl;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
Reference in new issue
Block a user