Add shift handover and RunPod, and make CX-Tools work in a container
Handover - The Confluence shift doc becomes the landing page: shift metadata, the top-of-page checks, key updates with their Zendesk/Jira refs and status, and the free-text comments. "Hand over shift" closes the shift, opens the next one and carries the live items across, dropping anything done or marked "remove at end of shift" - the retyping this replaces. - The RunPod table on that page is read from live host state instead of being copied in by hand, with the six-colour key preserved. RunPod - GraphQL client keyed on CX_RUNPOD_API_KEY. The old console login is kept as a fallback but cannot run unattended: the account has 2FA, so Clerk verifies the password and then asks for an emailed code and never issues a session. That is the real cause of the "No active session found" failure, and the client now says so instead of failing opaquely. TOTP is supported if the account moves to an authenticator app. - Hosts and their listing history are persisted, so "most problematic hosts" can be ranked and each machine has a timeline of who listed or unlisted it, with the Zendesk comment and the error hint. - The unlisting emails are parsed for the error block (they arrive quoted-printable) and classified into a likely cause and a next step. Zendesk and Jira - Unlisting raises a Zendesk ticket that follows the format of RunPod's own email, keyed on the machine so one machine keeps one thread, posted as an internal note. - Jira is split in two: the Infrahub/OIE instance and the RunPod/RMA one, which may be a different Atlassian site. Blank RunPod values fall back to the defaults rather than failing. Running in a container - CX-Tools reads its keys from 1Password, which needs a desktop app. Config is a dataclass whose lookups live in per-field default factories, so passing CX_INFRAHUB_TOKEN/CX_INFRAINSIGHT_TOKEN in means those factories never run and CX-Tools itself stays unmodified. - CX-Tools reaches OpenStack with `docker exec <region>-osc`, so the image now carries the Docker client (the static binary, not the docker.io package) and compose mounts the host socket with group_add for it. Verified from inside the container: live OpenStack and Infrahub calls both succeed. Also fixes Settings, which read the environment at class-definition time and so ignored anything set afterwards; a fresh Settings() silently returned stale values. Caught by the Jira scoping tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1951
frontend/package-lock.json
generated
Normal file
1951
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ import Login from "./pages/Login";
|
||||
import Queue from "./pages/Queue";
|
||||
import Linkage from "./pages/Linkage";
|
||||
import SettingsPage from "./pages/Settings";
|
||||
import HandoverPage from "./pages/Handover";
|
||||
import RunpodPage from "./pages/Runpod";
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
@@ -38,7 +40,9 @@ export default function App() {
|
||||
<div className="brand">
|
||||
{config?.app_name ?? "CX Triage"} <em>— alert triage</em>
|
||||
</div>
|
||||
<NavLink to="/" end className={({ isActive }) => `navlink${isActive ? " on" : ""}`}>Queue</NavLink>
|
||||
<NavLink to="/" end className={({ isActive }) => `navlink${isActive ? " on" : ""}`}>Handover</NavLink>
|
||||
<NavLink to="/alerts" className={({ isActive }) => `navlink${isActive ? " on" : ""}`}>Infrahub alerts</NavLink>
|
||||
<NavLink to="/runpod" className={({ isActive }) => `navlink${isActive ? " on" : ""}`}>RunPod</NavLink>
|
||||
{config?.linkage_scan && (
|
||||
<NavLink to="/linkage" className={({ isActive }) => `navlink${isActive ? " on" : ""}`}>Linkage</NavLink>
|
||||
)}
|
||||
@@ -51,7 +55,9 @@ export default function App() {
|
||||
<button className="sm" onClick={logout}>Sign out</button>
|
||||
</header>
|
||||
<Routes>
|
||||
<Route path="/" element={<Queue config={config!} />} />
|
||||
<Route path="/" element={<HandoverPage />} />
|
||||
<Route path="/alerts" element={<Queue config={config!} />} />
|
||||
<Route path="/runpod" element={<RunpodPage />} />
|
||||
<Route path="/linkage" element={<Linkage />} />
|
||||
<Route path="/settings" element={<SettingsPage user={user} config={config!} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
241
frontend/src/pages/Handover.tsx
Normal file
241
frontend/src/pages/Handover.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
interface Item {
|
||||
id: number; position: number; title: string; zendesk_tickets: string;
|
||||
jira_key: string; jira_status: string; body: string; links: string[];
|
||||
state: string; remove_at_end_of_shift: boolean; first_raised_on: string | null;
|
||||
carried_from_id: number | null;
|
||||
}
|
||||
interface RunpodRow {
|
||||
machine_id: string; name: string; colour: string; zendesk_ticket: string;
|
||||
jira_key: string; jira_status: string; gpu_reserved: number; gpu_total: number;
|
||||
last_error: string; next_steps: string; hours_unlisted: number | null; unlist_count: number;
|
||||
}
|
||||
interface Doc {
|
||||
id: number; title: string; shift: string; shift_date: string; handing_to: string;
|
||||
status: string; team_members: string; significant_issues_checked: boolean;
|
||||
hubspot_checked: boolean; total_open_tickets: number | null;
|
||||
member_checks: { name: string; hs_checked: boolean; jira_checked: boolean }[];
|
||||
other_comments: string; item_count: number; items: Item[];
|
||||
}
|
||||
|
||||
const STATES: Record<string, string> = {
|
||||
pending_infra: "Pending Infra", pending_customer: "Pending customer",
|
||||
pending_runpod: "Pending RunPod", pending_rma: "Pending RMA", pending_cx: "Pending CX",
|
||||
in_progress: "In progress", monitoring: "Monitoring",
|
||||
no_further_engagement: "No further engagement", done: "Done",
|
||||
};
|
||||
|
||||
const COLOURS: Record<string, { dot: string; text: string }> = {
|
||||
red: { dot: "#f2545b", text: "Blocked from relisting — recurring issue" },
|
||||
purple: { dot: "#a97bf0", text: "Pending RunPod" },
|
||||
yellow: { dot: "#e0a336", text: "Pending Infrastructure / DC team" },
|
||||
blue: { dot: "#59a0f5", text: "Pending removal from the RunPod platform" },
|
||||
green: { dot: "#35c46a", text: "Stress testing >24h — GPUs may look reserved" },
|
||||
white: { dot: "#8a93a5", text: "Actionable by CX" },
|
||||
};
|
||||
|
||||
const blankItem = (): Partial<Item> => ({
|
||||
title: "", zendesk_tickets: "", jira_key: "", jira_status: "", body: "",
|
||||
links: [], state: "in_progress", remove_at_end_of_shift: false,
|
||||
});
|
||||
|
||||
export default function HandoverPage() {
|
||||
const [doc, setDoc] = useState<Doc | null>(null);
|
||||
const [runpod, setRunpod] = useState<RunpodRow[]>([]);
|
||||
const [editing, setEditing] = useState<Partial<Item> | null>(null);
|
||||
const [saved, setSaved] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const r = await api.get<{ handover: Doc | null; runpod: RunpodRow[] }>("/api/handover/current");
|
||||
setDoc(r.handover); setRunpod(r.runpod ?? []);
|
||||
}, []);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const flash = (m: string) => { setSaved(m); setTimeout(() => setSaved(""), 2200); };
|
||||
|
||||
const saveDoc = async (patch: Partial<Doc>) => {
|
||||
if (!doc) return;
|
||||
const next = await api.post<Doc>(`/api/handover/${doc.id}`, { ...doc, ...patch });
|
||||
setDoc(next); flash("Saved");
|
||||
};
|
||||
|
||||
const saveItem = async () => {
|
||||
if (!doc || !editing) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const path = editing.id
|
||||
? `/api/handover/${doc.id}/items/${editing.id}`
|
||||
: `/api/handover/${doc.id}/items`;
|
||||
setDoc(await api.post<Doc>(path, editing));
|
||||
setEditing(null); flash("Item saved");
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const handOver = async () => {
|
||||
if (!doc) return;
|
||||
if (!confirm("Close this shift and start the next one? Live items carry forward; " +
|
||||
"anything done or flagged 'remove at end of shift' is left behind.")) return;
|
||||
const r = await api.post<{ next: Doc; carried: number }>(`/api/handover/${doc.id}/hand-over`);
|
||||
setDoc(r.next); flash(`Handed over — ${r.carried} items carried forward`);
|
||||
};
|
||||
|
||||
if (!doc) {
|
||||
return (
|
||||
<main style={{ padding: 26 }}>
|
||||
<div className="card">
|
||||
<h3 className="sec">No handover yet</h3>
|
||||
<button className="pri" onClick={async () => {
|
||||
await api.post("/api/handover", { shift: "APAC", handing_to: "EMEA" });
|
||||
void load();
|
||||
}}>Start today's handover</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ padding: "22px 26px 80px", maxWidth: 1180 }}>
|
||||
<div className="row">
|
||||
<h2 style={{ margin: 0, fontSize: 20 }}>{doc.title}</h2>
|
||||
<span className="badge">{doc.shift} → {doc.handing_to}</span>
|
||||
<span className={`badge ${doc.status === "draft" ? "" : "ok"}`}>{doc.status.replace("_", " ")}</span>
|
||||
<span className="spacer" style={{ marginLeft: "auto" }} />
|
||||
{saved && <span className="t-ok" style={{ fontSize: 12.5 }}>{saved}</span>}
|
||||
<button className="pri" onClick={handOver} disabled={doc.status !== "draft"}>Hand over shift</button>
|
||||
</div>
|
||||
|
||||
<div className="card mt">
|
||||
<h3 className="sec">Shift</h3>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "190px 1fr", gap: "10px 12px", alignItems: "center" }}>
|
||||
<label className="hint" style={{ margin: 0 }}>CX team members</label>
|
||||
<input defaultValue={doc.team_members} onBlur={(e) => saveDoc({ team_members: e.target.value })} />
|
||||
<label className="hint" style={{ margin: 0 }}>Total open tickets</label>
|
||||
<input type="number" defaultValue={doc.total_open_tickets ?? undefined} style={{ maxWidth: 140 }}
|
||||
onBlur={(e) => saveDoc({ total_open_tickets: Number(e.target.value) })} />
|
||||
<label className="hint" style={{ margin: 0 }}>Checks</label>
|
||||
<div className="row">
|
||||
<label className="row" style={{ gap: 6 }}>
|
||||
<input type="checkbox" style={{ width: "auto" }} checked={doc.significant_issues_checked}
|
||||
onChange={(e) => saveDoc({ significant_issues_checked: e.target.checked })} />
|
||||
Active issues of significance
|
||||
</label>
|
||||
<label className="row" style={{ gap: 6 }}>
|
||||
<input type="checkbox" style={{ width: "auto" }} checked={doc.hubspot_checked}
|
||||
onChange={(e) => saveDoc({ hubspot_checked: e.target.checked })} />
|
||||
HubSpot “My tickets on hold”
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mt">
|
||||
<h3 className="sec" style={{ flex: 1, margin: 0 }}>Key updates ({doc.items.length})</h3>
|
||||
<button onClick={() => setEditing(blankItem())}>Add an update</button>
|
||||
</div>
|
||||
|
||||
{doc.items.map((it) => (
|
||||
<div className="card" key={it.id} style={{ marginBottom: 10 }}>
|
||||
<div className="row">
|
||||
<b style={{ flex: 1 }}>{it.title || "(untitled)"}</b>
|
||||
{it.carried_from_id && <span className="badge" title={`First raised ${it.first_raised_on}`}>carried over</span>}
|
||||
{it.remove_at_end_of_shift && <span className="badge">remove at end of shift</span>}
|
||||
<span className={`badge ${it.state === "done" ? "ok" : it.state.startsWith("pending") ? "real" : ""}`}>
|
||||
{STATES[it.state] ?? it.state}</span>
|
||||
<button className="sm" onClick={() => setEditing(it)}>Edit</button>
|
||||
<button className="sm del" onClick={async () => {
|
||||
if (!confirm("Remove this update?")) return;
|
||||
setDoc(await api.del<Doc>(`/api/handover/${doc.id}/items/${it.id}`));
|
||||
}}>Remove</button>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 14, marginTop: 2 }}>
|
||||
{it.zendesk_tickets && <span className="hint" style={{ margin: 0 }}>Zendesk {it.zendesk_tickets}</span>}
|
||||
{it.jira_key && <span className="hint" style={{ margin: 0 }}>{it.jira_key} {it.jira_status}</span>}
|
||||
</div>
|
||||
{it.body && <div style={{ whiteSpace: "pre-wrap", marginTop: 8, fontSize: 13.5 }}>{it.body}</div>}
|
||||
{(it.links ?? []).map((l) => (
|
||||
<div key={l}><a href={l} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>{l}</a></div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{editing && (
|
||||
<div className="card" style={{ borderColor: "var(--accent)" }}>
|
||||
<div className="fld"><label>Title</label>
|
||||
<input value={editing.title ?? ""} onChange={(e) => setEditing({ ...editing, title: e.target.value })} /></div>
|
||||
<div className="row">
|
||||
<div className="fld" style={{ flex: 1 }}><label>Zendesk ticket(s)</label>
|
||||
<input value={editing.zendesk_tickets ?? ""} placeholder="#8495, #8453"
|
||||
onChange={(e) => setEditing({ ...editing, zendesk_tickets: e.target.value })} /></div>
|
||||
<div className="fld" style={{ flex: 1 }}><label>Jira key</label>
|
||||
<input value={editing.jira_key ?? ""} placeholder="OIE-3213"
|
||||
onChange={(e) => setEditing({ ...editing, jira_key: e.target.value })} /></div>
|
||||
<div className="fld" style={{ flex: 1 }}><label>Jira status</label>
|
||||
<input value={editing.jira_status ?? ""} placeholder="In Progress"
|
||||
onChange={(e) => setEditing({ ...editing, jira_status: e.target.value })} /></div>
|
||||
</div>
|
||||
<div className="fld"><label>Detail</label>
|
||||
<textarea value={editing.body ?? ""} onChange={(e) => setEditing({ ...editing, body: e.target.value })} /></div>
|
||||
<div className="row">
|
||||
<div className="fld" style={{ flex: 1 }}><label>Status</label>
|
||||
<select value={editing.state ?? "in_progress"}
|
||||
onChange={(e) => setEditing({ ...editing, state: e.target.value })}>
|
||||
{Object.entries(STATES).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select></div>
|
||||
<label className="row" style={{ gap: 6, marginTop: 18 }}>
|
||||
<input type="checkbox" style={{ width: "auto" }} checked={!!editing.remove_at_end_of_shift}
|
||||
onChange={(e) => setEditing({ ...editing, remove_at_end_of_shift: e.target.checked })} />
|
||||
Remove at end of shift
|
||||
</label>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button className="pri" onClick={saveItem} disabled={busy}>Save</button>
|
||||
<button onClick={() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="sec" style={{ marginTop: 28 }}>RunPod — unlisted machines ({runpod.length})</h3>
|
||||
<div className="hint" style={{ marginTop: 0 }}>
|
||||
Live from RunPod, not retyped. Colours follow the handover key.
|
||||
</div>
|
||||
<div className="row" style={{ gap: 14, margin: "8px 0 12px" }}>
|
||||
{Object.entries(COLOURS).map(([k, v]) => (
|
||||
<span key={k} className="hint" style={{ margin: 0 }} title={v.text}>
|
||||
<i style={{ display: "inline-block", width: 9, height: 9, borderRadius: 2,
|
||||
background: v.dot, marginRight: 5 }} />{k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="card" style={{ padding: 0 }}>
|
||||
{runpod.length === 0 ? <div className="empty">Nothing unlisted.</div> : runpod.map((r, i) => (
|
||||
<div key={r.machine_id} style={{ padding: "10px 14px", borderTop: i ? "1px solid var(--line)" : undefined }}>
|
||||
<div className="row">
|
||||
<i style={{ width: 9, height: 9, borderRadius: 2, background: COLOURS[r.colour]?.dot ?? "#888" }}
|
||||
title={COLOURS[r.colour]?.text} />
|
||||
<b style={{ flex: 1 }}>{r.name}</b>
|
||||
<span className="hint" style={{ margin: 0, fontFamily: "var(--mono)" }}>{r.machine_id}</span>
|
||||
{r.zendesk_ticket && <span className="badge">ZD {r.zendesk_ticket}</span>}
|
||||
{r.jira_key && <span className="badge">{r.jira_key} {r.jira_status}</span>}
|
||||
<span className="badge">{r.gpu_reserved}/{r.gpu_total} GPU</span>
|
||||
{r.hours_unlisted != null && <span className="badge">{r.hours_unlisted}h</span>}
|
||||
{r.unlist_count > 3 && <span className="badge overdue">{r.unlist_count}× unlisted</span>}
|
||||
</div>
|
||||
{r.last_error && (
|
||||
<pre style={{ marginTop: 6, fontSize: 11.5, background: "var(--bg)" }}>{r.last_error}</pre>
|
||||
)}
|
||||
{r.next_steps && <div className="hint" style={{ marginTop: 4 }}>{r.next_steps}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 className="sec" style={{ marginTop: 28 }}>Other comments</h3>
|
||||
<div className="card">
|
||||
<textarea defaultValue={doc.other_comments} style={{ minHeight: 160 }}
|
||||
onBlur={(e) => saveDoc({ other_comments: e.target.value })} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
238
frontend/src/pages/Runpod.tsx
Normal file
238
frontend/src/pages/Runpod.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
interface Event {
|
||||
id: number; event_type: string; actor: string; detail: string;
|
||||
error_hint: string; zendesk_ticket: string; jira_key: string;
|
||||
gpu_reserved: number | null; occurred_at: string;
|
||||
}
|
||||
interface Host {
|
||||
machine_id: string; name: string; listed: boolean; gpu_reserved: number; gpu_total: number;
|
||||
gpu_type: string; data_center: string; colour: string; zendesk_ticket: string;
|
||||
jira_key: string; jira_status: string; last_error: string; next_steps: string;
|
||||
unlist_count: number; historic_count: number; hours_unlisted: number | null;
|
||||
event_count: number; effective_count?: number; last_unlisted?: string | null;
|
||||
events?: Event[];
|
||||
}
|
||||
|
||||
const DOT: Record<string, string> = {
|
||||
red: "#f2545b", purple: "#a97bf0", yellow: "#e0a336",
|
||||
blue: "#59a0f5", green: "#35c46a", white: "#8a93a5",
|
||||
};
|
||||
|
||||
const EVENT_STYLE: Record<string, string> = {
|
||||
unlisted: "t-bad", listed: "t-ok", drained: "t-warn",
|
||||
maintenance_scheduled: "t-warn", zendesk_ticket: "", jira_linked: "", note: "",
|
||||
};
|
||||
|
||||
export default function RunpodPage() {
|
||||
const [tab, setTab] = useState<"current" | "history">("current");
|
||||
const [status, setStatus] = useState<any>(null);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [problem, setProblem] = useState<Host[]>([]);
|
||||
const [open, setOpen] = useState<Host | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [ticket, setTicket] = useState<any>(null);
|
||||
const [sending, setSending] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [s, h, p] = await Promise.all([
|
||||
api.get<any>("/api/runpod/status"),
|
||||
api.get<{ hosts: Host[] }>("/api/runpod/hosts?unlisted_only=true"),
|
||||
api.get<{ hosts: Host[] }>("/api/runpod/problem-hosts?limit=50"),
|
||||
]);
|
||||
setStatus(s); setHosts(h.hosts); setProblem(p.hosts);
|
||||
}, []);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const openHost = async (machineId: string) => {
|
||||
setTicket(null); setSending("");
|
||||
const [host, preview] = await Promise.all([
|
||||
api.get<Host>(`/api/runpod/hosts/${machineId}`),
|
||||
api.get<any>(`/api/runpod/hosts/${machineId}/ticket-preview`).catch(() => null),
|
||||
]);
|
||||
setOpen(host); setTicket(preview);
|
||||
};
|
||||
|
||||
const act = async (path: string, label: string) => {
|
||||
if (!open) return;
|
||||
setSending(label);
|
||||
try {
|
||||
const r = await api.post<any>(`/api/runpod/hosts/${open.machine_id}/${path}`, {});
|
||||
alert(`${label}: ${r.action} — ${r.ticket_id ?? r.key}`);
|
||||
await openHost(open.machine_id); await load();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : `${label} failed`);
|
||||
} finally { setSending(""); }
|
||||
};
|
||||
|
||||
if (!status) return <div className="empty"><span className="spin" /></div>;
|
||||
|
||||
return (
|
||||
<main style={{ padding: "22px 26px 80px", maxWidth: 1400 }}>
|
||||
<div className="row">
|
||||
<h2 style={{ margin: 0, fontSize: 20 }}>RunPod</h2>
|
||||
<span className="badge">{status.totals.machines} machines</span>
|
||||
<span className="badge overdue">{status.totals.unlisted} unlisted</span>
|
||||
<span className="badge">{status.totals.gpus_rented}/{status.totals.gpus_total} GPUs rented</span>
|
||||
<span className="badge" title="api_key is the supported mode; console_login cannot run unattended">
|
||||
auth: {status.mode}</span>
|
||||
<span className="spacer" style={{ marginLeft: "auto" }} />
|
||||
<button disabled={!status.configured || busy} onClick={async () => {
|
||||
setBusy(true);
|
||||
try { const r = await api.post<any>("/api/runpod/sync"); await load();
|
||||
alert(`Synced ${r.machines} machines — ${r.unlisted} newly unlisted, ${r.relisted} relisted, ${r.drained} drained`); }
|
||||
catch (e) { alert(e instanceof Error ? e.message : "Sync failed"); }
|
||||
finally { setBusy(false); }
|
||||
}}>{busy ? "Syncing…" : "Sync from RunPod"}</button>
|
||||
</div>
|
||||
|
||||
{!status.configured && (
|
||||
<div className="warnbox mt">
|
||||
RunPod is not configured. Set <code>CX_RUNPOD_API_KEY</code>. The email/password fallback
|
||||
cannot run unattended — the account has 2FA, so no session is ever created.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row mt">
|
||||
<span className={`chip${tab === "current" ? " on" : ""}`} onClick={() => setTab("current")}>
|
||||
Unlisted now <b>{hosts.length}</b></span>
|
||||
<span className={`chip${tab === "history" ? " on" : ""}`} onClick={() => setTab("history")}>
|
||||
Problem hosts <b>{problem.length}</b></span>
|
||||
</div>
|
||||
|
||||
{tab === "current" && (
|
||||
<div className="card mt" style={{ padding: 0 }}>
|
||||
{hosts.length === 0 ? <div className="empty">Nothing unlisted.</div> : hosts.map((h, i) => (
|
||||
<div key={h.machine_id} style={{ padding: "10px 14px", borderTop: i ? "1px solid var(--line)" : undefined, cursor: "pointer" }}
|
||||
onClick={() => void openHost(h.machine_id)}>
|
||||
<div className="row">
|
||||
<i style={{ width: 9, height: 9, borderRadius: 2, background: DOT[h.colour] ?? "#888" }} />
|
||||
<b style={{ flex: 1 }}>{h.name}</b>
|
||||
<span className="hint" style={{ margin: 0, fontFamily: "var(--mono)" }}>{h.machine_id}</span>
|
||||
{h.zendesk_ticket && <span className="badge">ZD {h.zendesk_ticket}</span>}
|
||||
{h.jira_key && <span className="badge">{h.jira_key}</span>}
|
||||
<span className="badge">{h.gpu_reserved}/{h.gpu_total} GPU</span>
|
||||
{h.hours_unlisted != null && <span className="badge">{h.hours_unlisted}h</span>}
|
||||
</div>
|
||||
{h.last_error && <div className="hint" style={{ marginTop: 4, whiteSpace: "pre-wrap" }}>
|
||||
{h.last_error.split("\n")[0]}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "history" && (
|
||||
<>
|
||||
<div className="hint">
|
||||
Ranked by how often each machine has been unlisted. Repeat offenders are the RMA
|
||||
conversation, not another burn-in.
|
||||
</div>
|
||||
<div className="card mt" style={{ padding: 0 }}>
|
||||
{problem.map((h, i) => (
|
||||
<div key={h.machine_id} className="row"
|
||||
style={{ padding: "9px 14px", borderTop: i ? "1px solid var(--line)" : undefined, cursor: "pointer" }}
|
||||
onClick={() => void openHost(h.machine_id)}>
|
||||
<span style={{ width: 26, color: "var(--faint)", fontFamily: "var(--mono)" }}>{i + 1}</span>
|
||||
<i style={{ width: 9, height: 9, borderRadius: 2, background: DOT[h.colour] ?? "#888" }} />
|
||||
<b style={{ flex: 1 }}>{h.name}</b>
|
||||
<span className={h.listed ? "t-ok" : "t-bad"} style={{ fontSize: 12 }}>
|
||||
{h.listed ? "listed" : "unlisted"}</span>
|
||||
<span className="badge overdue">{h.effective_count ?? h.unlist_count}× unlisted</span>
|
||||
<span className="badge">{h.event_count} events</span>
|
||||
{h.last_unlisted && <span className="hint" style={{ margin: 0 }}>
|
||||
last {h.last_unlisted.slice(0, 10)}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="scrim" onClick={() => setOpen(null)} />
|
||||
<aside className="drawer">
|
||||
<div className="dh">
|
||||
<h2>{open.name}</h2>
|
||||
<button className="sm" onClick={() => setOpen(null)}>Close</button>
|
||||
</div>
|
||||
<div className="db">
|
||||
<div className="row">
|
||||
<span className="badge" style={{ fontFamily: "var(--mono)" }}>{open.machine_id}</span>
|
||||
<span className={open.listed ? "badge ok" : "badge overdue"}>
|
||||
{open.listed ? "listed" : "unlisted"}</span>
|
||||
<span className="badge">{open.gpu_reserved}/{open.gpu_total} GPU</span>
|
||||
{open.data_center && <span className="badge">{open.data_center}</span>}
|
||||
<span className="badge">{open.unlist_count}× unlisted</span>
|
||||
</div>
|
||||
|
||||
{open.last_error && (
|
||||
<>
|
||||
<h3 className="sec" style={{ marginTop: 16 }}>Last error</h3>
|
||||
<pre>{open.last_error}</pre>
|
||||
</>
|
||||
)}
|
||||
{open.next_steps && (
|
||||
<>
|
||||
<h3 className="sec" style={{ marginTop: 16 }}>Next steps</h3>
|
||||
<div style={{ fontSize: 13 }}>{open.next_steps}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ticket && (
|
||||
<>
|
||||
<h3 className="sec" style={{ marginTop: 18 }}>Actions</h3>
|
||||
{(!ticket.send_enabled || !ticket.zendesk_ready) && (
|
||||
<div className="warnbox">
|
||||
<b>Preview only.</b>{" "}
|
||||
{!ticket.send_enabled
|
||||
? "Sending is off on this instance (CX_FEATURE_SEND_ENABLED)."
|
||||
: "Zendesk is not configured."}{" "}Nothing will be sent.
|
||||
</div>
|
||||
)}
|
||||
<div className="row">
|
||||
<button className="pri"
|
||||
disabled={!ticket.send_enabled || !ticket.zendesk_ready || !!sending}
|
||||
onClick={() => act("zendesk", "Zendesk ticket")}>
|
||||
{sending === "Zendesk ticket" ? "Raising…" : "Raise Zendesk ticket"}
|
||||
</button>
|
||||
<button disabled={!ticket.send_enabled || !ticket.runpod_jira_ready || !!sending}
|
||||
onClick={() => act("rma", "RMA issue")}>
|
||||
{sending === "RMA issue" ? "Raising…" : "Raise RMA issue"}
|
||||
</button>
|
||||
{ticket.existing_ticket && <span className="badge">ZD {ticket.existing_ticket}</span>}
|
||||
{ticket.existing_jira && <span className="badge">{ticket.existing_jira}</span>}
|
||||
</div>
|
||||
<details className="fold" style={{ marginTop: 10 }}>
|
||||
<summary>Ticket that would be raised</summary>
|
||||
<div className="foldb">
|
||||
<div className="hint" style={{ marginTop: 0 }}>{ticket.subject}</div>
|
||||
<pre style={{ marginTop: 6 }}>{ticket.body}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3 className="sec" style={{ marginTop: 18 }}>History ({open.events?.length ?? 0})</h3>
|
||||
<ul className="timeline">
|
||||
{(open.events ?? []).map((e) => (
|
||||
<li key={e.id}>
|
||||
<b className={EVENT_STYLE[e.event_type] ?? ""}>{e.event_type.replace(/_/g, " ")}</b>
|
||||
{" — "}{e.detail}
|
||||
{e.zendesk_ticket && <span className="badge" style={{ marginLeft: 6 }}>ZD {e.zendesk_ticket}</span>}
|
||||
{e.jira_key && <span className="badge" style={{ marginLeft: 6 }}>{e.jira_key}</span>}
|
||||
{e.error_hint && <pre style={{ marginTop: 5 }}>{e.error_hint}</pre>}
|
||||
<div className="ts">
|
||||
{new Date(e.occurred_at).toLocaleString()} · {e.actor}
|
||||
{e.gpu_reserved != null && ` · ${e.gpu_reserved} GPU rented`}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user