@volter/twin-sentry 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +142 -0
- package/client/sentry-mirror.css +99 -0
- package/client/sentry-mirror.tsx +352 -0
- package/dist/client/sentry-mirror.bundle.js +321 -0
- package/dist/client/sentry-mirror.css +99 -0
- package/dist/client/sentry-mirror.d.ts +17 -0
- package/dist/client/sentry-mirror.js +156 -0
- package/dist/client/sentry-mirror.tsx +352 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +36 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +75 -0
- package/dist/src/sentry-budget.d.ts +50 -0
- package/dist/src/sentry-budget.js +145 -0
- package/dist/src/sentry-capabilities.d.ts +3 -0
- package/dist/src/sentry-capabilities.js +1180 -0
- package/dist/src/sentry-conformance.d.ts +22 -0
- package/dist/src/sentry-conformance.js +97 -0
- package/dist/src/sentry-connector.d.ts +77 -0
- package/dist/src/sentry-connector.js +226 -0
- package/dist/src/sentry-events.d.ts +54 -0
- package/dist/src/sentry-events.js +131 -0
- package/dist/src/sentry-ingest.d.ts +137 -0
- package/dist/src/sentry-ingest.js +387 -0
- package/dist/src/sentry-mirror-ui.d.ts +38 -0
- package/dist/src/sentry-mirror-ui.js +162 -0
- package/dist/src/sentry-perform-harness.d.ts +9 -0
- package/dist/src/sentry-perform-harness.js +20 -0
- package/dist/src/sentry-server.d.ts +14 -0
- package/dist/src/sentry-server.js +27 -0
- package/dist/src/sentry-twin.d.ts +17 -0
- package/dist/src/sentry-twin.js +1739 -0
- package/dist/test-fixtures/sentry-openapi-operations.SOURCE.md +24 -0
- package/dist/test-fixtures/sentry-openapi-operations.json +1837 -0
- package/package.json +75 -0
- package/src/cli.ts +34 -0
- package/src/index.ts +145 -0
- package/src/sentry-budget.ts +171 -0
- package/src/sentry-capabilities.ts +1222 -0
- package/src/sentry-conformance.ts +110 -0
- package/src/sentry-connector.ts +260 -0
- package/src/sentry-events.ts +171 -0
- package/src/sentry-ingest.ts +471 -0
- package/src/sentry-mirror-ui.ts +162 -0
- package/src/sentry-perform-harness.ts +19 -0
- package/src/sentry-server.ts +35 -0
- package/src/sentry-twin.ts +1615 -0
- package/test-fixtures/sentry-openapi-operations.SOURCE.md +24 -0
- package/test-fixtures/sentry-openapi-operations.json +1837 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #1d1127;
|
|
3
|
+
--panel: #251a35;
|
|
4
|
+
--panel-2: #2e2140;
|
|
5
|
+
--border: #3a2d50;
|
|
6
|
+
--text: #ebe6f2;
|
|
7
|
+
--muted: #a094b3;
|
|
8
|
+
--accent: #6c5fc7;
|
|
9
|
+
--fatal: #c84b48;
|
|
10
|
+
--error: #e4774e;
|
|
11
|
+
--warning: #e2c14f;
|
|
12
|
+
--info: #5b9bd5;
|
|
13
|
+
--debug: #8a7fb0;
|
|
14
|
+
--resolved: #3aa76d;
|
|
15
|
+
--ignored: #6b5f82;
|
|
16
|
+
}
|
|
17
|
+
* { box-sizing: border-box; }
|
|
18
|
+
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); }
|
|
19
|
+
.mono { font-family: "SFMono-Regular", Menlo, Consolas, monospace; }
|
|
20
|
+
.app { display: flex; flex-direction: column; height: 100vh; }
|
|
21
|
+
.topbar { display: flex; align-items: center; gap: 16px; padding: 0 18px; height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); }
|
|
22
|
+
.brand { font-weight: 700; font-size: 16px; }
|
|
23
|
+
.badge { font-size: 11px; background: var(--accent); padding: 2px 6px; border-radius: 4px; vertical-align: middle; }
|
|
24
|
+
.org { color: var(--muted); font-size: 13px; }
|
|
25
|
+
.body { display: flex; flex: 1; min-height: 0; }
|
|
26
|
+
.sidenav { width: 180px; background: var(--panel); border-right: 1px solid var(--border); padding: 12px 8px; display: flex; flex-direction: column; gap: 4px; }
|
|
27
|
+
.nav-item { text-align: left; background: none; border: none; color: var(--muted); padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
|
28
|
+
.nav-item:hover { background: var(--panel-2); color: var(--text); }
|
|
29
|
+
.nav-item.active { background: var(--accent); color: #fff; }
|
|
30
|
+
.content { flex: 1; min-width: 0; overflow: hidden; display: flex; }
|
|
31
|
+
.split { display: flex; flex: 1; min-width: 0; }
|
|
32
|
+
.list { width: 480px; border-right: 1px solid var(--border); overflow-y: auto; }
|
|
33
|
+
.list.full { width: 100%; padding: 18px; }
|
|
34
|
+
.detail { flex: 1; overflow-y: auto; padding: 22px; }
|
|
35
|
+
.search-bar { padding: 12px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); }
|
|
36
|
+
.search-input { width: 100%; padding: 8px 10px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: monospace; }
|
|
37
|
+
.issue-row { display: flex; justify-content: space-between; align-items: center; width: 100%; text-align: left; background: none; border: none; border-bottom: 1px solid var(--border); color: var(--text); padding: 12px 14px; cursor: pointer; }
|
|
38
|
+
.issue-row:hover { background: var(--panel-2); }
|
|
39
|
+
.issue-row.selected { background: var(--panel-2); border-left: 3px solid var(--accent); }
|
|
40
|
+
.issue-title { font-weight: 600; font-size: 14px; }
|
|
41
|
+
.issue-culprit { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
|
42
|
+
.issue-meta { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
|
43
|
+
.issue-count, .issue-lastseen { color: var(--muted); font-size: 12px; }
|
|
44
|
+
.pill { font-size: 11px; padding: 2px 7px; border-radius: 10px; text-transform: uppercase; font-weight: 600; }
|
|
45
|
+
.level-fatal { background: var(--fatal); color: #fff; }
|
|
46
|
+
.level-error { background: var(--error); color: #fff; }
|
|
47
|
+
.level-warning { background: var(--warning); color: #1d1127; }
|
|
48
|
+
.level-info { background: var(--info); color: #fff; }
|
|
49
|
+
.level-debug, .level-muted { background: var(--debug); color: #fff; }
|
|
50
|
+
.status-unresolved { background: #43354f; color: var(--text); }
|
|
51
|
+
.status-resolved { background: var(--resolved); color: #fff; }
|
|
52
|
+
.status-ignored, .status-muted { background: var(--ignored); color: #fff; }
|
|
53
|
+
.detail-title { margin: 0 0 4px; font-size: 20px; }
|
|
54
|
+
.detail-sub { color: var(--muted); margin-bottom: 16px; }
|
|
55
|
+
.detail-stats { display: flex; gap: 26px; margin-bottom: 22px; }
|
|
56
|
+
.detail-stats .stat-label { display: block; color: var(--muted); font-size: 11px; text-transform: uppercase; }
|
|
57
|
+
.detail-stats .stat-value { font-size: 18px; font-weight: 600; }
|
|
58
|
+
.stacktrace { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
59
|
+
.frame { display: flex; justify-content: space-between; padding: 7px 12px; border-bottom: 1px solid var(--border); font-size: 12px; }
|
|
60
|
+
.frame:last-child { border-bottom: none; }
|
|
61
|
+
.frame-fn { color: var(--accent); }
|
|
62
|
+
.frame-file { color: var(--muted); }
|
|
63
|
+
.tags { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
64
|
+
.tag { background: var(--panel); border: 1px solid var(--border); padding: 4px 8px; border-radius: 4px; font-size: 12px; }
|
|
65
|
+
.empty { color: var(--muted); padding: 24px; text-align: center; }
|
|
66
|
+
.screen-title { margin: 0 0 16px; }
|
|
67
|
+
.release-row, .project-row { display: flex; gap: 18px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
|
68
|
+
.release-version, .project-name { font-weight: 600; min-width: 200px; }
|
|
69
|
+
.release-commits, .release-date, .project-slug, .project-platform { color: var(--muted); font-size: 13px; }
|
|
70
|
+
h3 { margin: 22px 0 10px; font-size: 14px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
|
71
|
+
.alerts-sub { color: var(--muted); font-size: 13px; margin-bottom: 14px; }
|
|
72
|
+
.alert-rule-row { display: flex; gap: 18px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
|
73
|
+
.alert-rule-name { font-weight: 600; min-width: 200px; }
|
|
74
|
+
.alert-rule-triggers { display: flex; gap: 6px; }
|
|
75
|
+
.alert-trigger { background: #43354f; color: var(--text); }
|
|
76
|
+
.alert-rule-endpoints { color: var(--muted); font-size: 13px; }
|
|
77
|
+
.settings-section { margin-bottom: 24px; }
|
|
78
|
+
.setting-field, .dsn-row { display: flex; gap: 18px; align-items: center; padding: 8px 14px; border-bottom: 1px solid var(--border); }
|
|
79
|
+
.setting-label, .dsn-name { color: var(--muted); min-width: 120px; }
|
|
80
|
+
.setting-value, .dsn-public { font-weight: 500; }
|
|
81
|
+
.dsn-public { color: var(--accent); font-size: 12px; }
|
|
82
|
+
.env-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
83
|
+
.env-item { background: var(--panel); border: 1px solid var(--border); color: var(--text); }
|
|
84
|
+
.dashboards-sub, .discover-results-note { color: var(--muted); font-size: 13px; margin-bottom: 14px; }
|
|
85
|
+
.dashboard-row { padding: 14px; border-bottom: 1px solid var(--border); }
|
|
86
|
+
.dashboard-title { font-weight: 600; font-size: 15px; display: block; margin-bottom: 10px; }
|
|
87
|
+
.dashboard-widgets { display: flex; flex-wrap: wrap; gap: 10px; }
|
|
88
|
+
.widget-card { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; min-width: 160px; background: #1c1530; border: 1px solid var(--border); border-radius: 8px; }
|
|
89
|
+
.widget-title { font-weight: 500; }
|
|
90
|
+
.widget-type { background: #43354f; color: var(--text); align-self: flex-start; }
|
|
91
|
+
.discover-builder { display: flex; flex-direction: column; gap: 12px; padding: 14px; background: #1c1530; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 14px; }
|
|
92
|
+
.discover-field { display: flex; flex-direction: column; gap: 6px; }
|
|
93
|
+
.discover-label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
|
94
|
+
.discover-columns, .discover-query { padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border); background: #120c22; color: var(--text); font: inherit; }
|
|
95
|
+
.discover-columns-preview { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
96
|
+
.discover-column-chip { padding: 4px 8px; background: #2a2140; border-radius: 5px; font-size: 12px; }
|
|
97
|
+
.saved-query-row { display: flex; gap: 18px; align-items: center; padding: 10px 14px; border-bottom: 1px solid var(--border); }
|
|
98
|
+
.saved-query-name { font-weight: 600; min-width: 180px; }
|
|
99
|
+
.saved-query-fields { color: var(--muted); font-size: 12px; }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export declare function ReleasesView({ releases }: {
|
|
3
|
+
releases: any[];
|
|
4
|
+
}): React.JSX.Element;
|
|
5
|
+
export declare function ProjectsView({ projects }: {
|
|
6
|
+
projects: any[];
|
|
7
|
+
}): React.JSX.Element;
|
|
8
|
+
export declare function DashboardsView({ dashboards }: {
|
|
9
|
+
dashboards: any[];
|
|
10
|
+
}): React.JSX.Element;
|
|
11
|
+
export declare function DiscoverView({ saved, fields, query, onFields, onQuery }: {
|
|
12
|
+
saved: any[];
|
|
13
|
+
fields?: string;
|
|
14
|
+
query?: string;
|
|
15
|
+
onFields?: (v: string) => void;
|
|
16
|
+
onQuery?: (v: string) => void;
|
|
17
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Sentry UI mirror — React/TSX client (Bun-bundled). A Sentry-dashboard-style view:
|
|
3
|
+
// top bar + left nav + an Issues stream (grouped errors with count / last-seen / level /
|
|
4
|
+
// status), an Issue detail (latest event stacktrace + tags), and Releases — all backed by
|
|
5
|
+
// REAL twin state via the twin's own Web API (/api/0/...). API↔UI parity.
|
|
6
|
+
//
|
|
7
|
+
// Pure format/level helpers live in ../src/sentry-mirror-ui.ts (Bun tree-shakes the
|
|
8
|
+
// server-only exports out of this browser bundle).
|
|
9
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
10
|
+
import { createRoot } from 'react-dom/client';
|
|
11
|
+
import { levelTone, statusTone, formatCount, timeAgo, latestStacktrace, eventTags, MIRROR_DEFAULT_ORG, MIRROR_DEFAULT_PROJECT, } from "../src/sentry-mirror-ui.js";
|
|
12
|
+
// WHERE THIS MIRROR LIVES. Served at a vendor root the base is '' (fetches are root-relative, as
|
|
13
|
+
// before); served under a path prefix with a <base> tag every read and write resolves inside that
|
|
14
|
+
// prefix instead of escaping it.
|
|
15
|
+
const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
|
|
16
|
+
const NAV = [
|
|
17
|
+
{ key: 'issues', label: 'Issues' },
|
|
18
|
+
{ key: 'dashboards', label: 'Dashboards' },
|
|
19
|
+
{ key: 'discover', label: 'Discover' },
|
|
20
|
+
{ key: 'releases', label: 'Releases' },
|
|
21
|
+
{ key: 'projects', label: 'Projects' },
|
|
22
|
+
{ key: 'alerts', label: 'Alerts' },
|
|
23
|
+
{ key: 'settings', label: 'Settings' },
|
|
24
|
+
];
|
|
25
|
+
async function api(path) {
|
|
26
|
+
const res = await fetch(`${WIRE_BASE}/api/0${path}`);
|
|
27
|
+
try {
|
|
28
|
+
return await res.json();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function LevelPill({ level }) {
|
|
35
|
+
return _jsx("span", { className: `pill level-${levelTone(level)}`, children: String(level ?? '').toUpperCase() || '—' });
|
|
36
|
+
}
|
|
37
|
+
function StatusPill({ status }) {
|
|
38
|
+
return _jsx("span", { className: `pill status-${statusTone(status)}`, children: String(status ?? '').replace(/_/g, ' ') || '—' });
|
|
39
|
+
}
|
|
40
|
+
function IssueRowView({ issue, selected, onSelect }) {
|
|
41
|
+
return (_jsxs("button", { className: `issue-row ${selected ? 'selected' : ''}`, onClick: onSelect, children: [_jsxs("div", { className: "issue-main", children: [_jsx("div", { className: "issue-title", children: issue.title }), _jsx("div", { className: "issue-culprit mono", children: issue.culprit || issue.shortId })] }), _jsxs("div", { className: "issue-meta", children: [_jsx(LevelPill, { level: issue.level }), _jsx(StatusPill, { status: issue.status }), _jsxs("span", { className: "issue-count", title: "event count", children: [formatCount(issue.count), " events"] }), _jsx("span", { className: "issue-lastseen", children: timeAgo(issue.lastSeen) })] })] }));
|
|
42
|
+
}
|
|
43
|
+
function Stacktrace({ frames }) {
|
|
44
|
+
if (frames.length === 0)
|
|
45
|
+
return _jsx("div", { className: "empty", children: "No stacktrace" });
|
|
46
|
+
return (_jsx("div", { className: "stacktrace", children: frames.map((f, i) => (_jsxs("div", { className: "frame mono", children: [_jsx("span", { className: "frame-fn", children: f.function ?? '?' }), _jsxs("span", { className: "frame-file", children: [f.filename ?? f.module ?? '', f.lineno ? `:${f.lineno}` : ''] })] }, i))) }));
|
|
47
|
+
}
|
|
48
|
+
function IssueDetail({ issue }) {
|
|
49
|
+
const [event, setEvent] = useState(null);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
let live = true;
|
|
52
|
+
api(`/issues/${issue.id}/events/latest/`).then((e) => { if (live)
|
|
53
|
+
setEvent(e); });
|
|
54
|
+
return () => { live = false; };
|
|
55
|
+
}, [issue.id]);
|
|
56
|
+
const frames = useMemo(() => latestStacktrace(event), [event]);
|
|
57
|
+
const tags = useMemo(() => eventTags(event), [event]);
|
|
58
|
+
return (_jsxs("div", { className: "issue-detail", children: [_jsx("h2", { className: "detail-title", children: issue.title }), _jsx("div", { className: "detail-sub mono", children: issue.culprit }), _jsxs("div", { className: "detail-stats", children: [_jsxs("div", { children: [_jsx("span", { className: "stat-label", children: "Events" }), _jsx("span", { className: "stat-value", children: formatCount(issue.count) })] }), _jsxs("div", { children: [_jsx("span", { className: "stat-label", children: "First seen" }), _jsx("span", { className: "stat-value", children: timeAgo(issue.firstSeen) })] }), _jsxs("div", { children: [_jsx("span", { className: "stat-label", children: "Last seen" }), _jsx("span", { className: "stat-value", children: timeAgo(issue.lastSeen) })] }), _jsxs("div", { children: [_jsx("span", { className: "stat-label", children: "Status" }), _jsx(StatusPill, { status: issue.status })] })] }), _jsx("h3", { children: "Latest Event Stacktrace" }), _jsx(Stacktrace, { frames: frames }), _jsx("h3", { children: "Tags" }), tags.length === 0 ? _jsx("div", { className: "empty", children: "No tags" }) : (_jsx("div", { className: "tags", children: tags.map((t) => _jsxs("span", { className: "tag mono", children: [t.key, ": ", t.value] }, t.key)) }))] }));
|
|
59
|
+
}
|
|
60
|
+
function IssuesScreen() {
|
|
61
|
+
const [issues, setIssues] = useState([]);
|
|
62
|
+
const [selected, setSelected] = useState(null);
|
|
63
|
+
const [query, setQuery] = useState('is:unresolved');
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
api(`/projects/${MIRROR_DEFAULT_ORG}/${MIRROR_DEFAULT_PROJECT}/issues/?query=${encodeURIComponent(query)}`)
|
|
66
|
+
.then((rows) => setIssues(Array.isArray(rows) ? rows : []));
|
|
67
|
+
}, [query]);
|
|
68
|
+
const current = issues.find((i) => i.id === selected) ?? issues[0];
|
|
69
|
+
return (_jsxs("div", { className: "split", children: [_jsxs("div", { className: "list", children: [_jsx("div", { className: "search-bar", children: _jsx("input", { className: "search-input", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "is:unresolved level:error ..." }) }), issues.length === 0 ? _jsx("div", { className: "empty", children: "No issues" }) :
|
|
70
|
+
issues.map((i) => _jsx(IssueRowView, { issue: i, selected: current?.id === i.id, onSelect: () => setSelected(i.id) }, i.id))] }), _jsx("div", { className: "detail", children: current ? _jsx(IssueDetail, { issue: current }) : _jsx("div", { className: "empty", children: "Select an issue" }) })] }));
|
|
71
|
+
}
|
|
72
|
+
// Presentational (data-in, no fetch): the Releases screen rendered over a given list of
|
|
73
|
+
// releases. Exported so verify() can render it offline over seeded twin state with
|
|
74
|
+
// react-dom/server and assert one `release-row` per seeded release carrying its version.
|
|
75
|
+
export function ReleasesView({ releases }) {
|
|
76
|
+
return (_jsxs("div", { className: "list full", children: [_jsx("h2", { className: "screen-title", children: "Releases" }), releases.length === 0 ? _jsx("div", { className: "empty", children: "No releases" }) :
|
|
77
|
+
releases.map((r) => (_jsxs("div", { className: "release-row", children: [_jsx("span", { className: "release-version mono", children: r.version }), _jsxs("span", { className: "release-commits", children: [r.commitCount ?? 0, " commits"] }), _jsx("span", { className: "release-date", children: timeAgo(r.dateCreated) })] }, r.id)))] }));
|
|
78
|
+
}
|
|
79
|
+
function ReleasesScreen() {
|
|
80
|
+
const [releases, setReleases] = useState([]);
|
|
81
|
+
useEffect(() => { api(`/organizations/${MIRROR_DEFAULT_ORG}/releases/`).then((r) => setReleases(Array.isArray(r) ? r : [])); }, []);
|
|
82
|
+
return _jsx(ReleasesView, { releases: releases });
|
|
83
|
+
}
|
|
84
|
+
// Presentational (data-in, no fetch): the Projects screen rendered over a given list of
|
|
85
|
+
// projects. Exported so verify() can render it offline over seeded twin state with
|
|
86
|
+
// react-dom/server and assert one `project-row` per seeded project carrying its slug.
|
|
87
|
+
export function ProjectsView({ projects }) {
|
|
88
|
+
return (_jsxs("div", { className: "list full", children: [_jsx("h2", { className: "screen-title", children: "Projects" }), projects.length === 0 ? _jsx("div", { className: "empty", children: "No projects" }) :
|
|
89
|
+
projects.map((p) => (_jsxs("div", { className: "project-row", children: [_jsx("span", { className: "project-name", children: p.name }), _jsx("span", { className: "project-slug mono", children: p.slug }), _jsx("span", { className: "project-platform", children: p.platform ?? '—' })] }, p.id)))] }));
|
|
90
|
+
}
|
|
91
|
+
function ProjectsScreen() {
|
|
92
|
+
const [projects, setProjects] = useState([]);
|
|
93
|
+
useEffect(() => { api(`/projects/`).then((p) => setProjects(Array.isArray(p) ? p : [])); }, []);
|
|
94
|
+
return _jsx(ProjectsView, { projects: projects });
|
|
95
|
+
}
|
|
96
|
+
function AlertsScreen() {
|
|
97
|
+
const [rules, setRules] = useState([]);
|
|
98
|
+
useEffect(() => { api(`/projects/${MIRROR_DEFAULT_ORG}/${MIRROR_DEFAULT_PROJECT}/rules/`).then((r) => setRules(Array.isArray(r) ? r : [])); }, []);
|
|
99
|
+
return (_jsxs("div", { className: "list full alerts-screen", children: [_jsx("h2", { className: "screen-title", children: "Alerts" }), _jsxs("div", { className: "alerts-sub", children: ["Issue alert rules for ", MIRROR_DEFAULT_PROJECT] }), rules.length === 0 ? _jsx("div", { className: "empty", children: "No alert rules" }) :
|
|
100
|
+
rules.map((r) => (_jsxs("div", { className: "alert-rule-row", children: [_jsx("span", { className: "alert-rule-name", children: r.name }), _jsx("span", { className: "alert-rule-triggers", children: (Array.isArray(r.triggers) ? r.triggers : []).map((t) => _jsx("span", { className: "alert-trigger pill", children: t }, t)) }), _jsxs("span", { className: "alert-rule-endpoints mono", children: [(Array.isArray(r.endpoints) ? r.endpoints : []).length, " endpoint(s)"] })] }, r.id)))] }));
|
|
101
|
+
}
|
|
102
|
+
function SettingsScreen() {
|
|
103
|
+
const [project, setProject] = useState(null);
|
|
104
|
+
const [keys, setKeys] = useState([]);
|
|
105
|
+
const [envs, setEnvs] = useState([]);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
api(`/projects/${MIRROR_DEFAULT_ORG}/${MIRROR_DEFAULT_PROJECT}/`).then(setProject);
|
|
108
|
+
api(`/projects/${MIRROR_DEFAULT_ORG}/${MIRROR_DEFAULT_PROJECT}/keys/`).then((k) => setKeys(Array.isArray(k) ? k : []));
|
|
109
|
+
api(`/projects/${MIRROR_DEFAULT_ORG}/${MIRROR_DEFAULT_PROJECT}/environments/`).then((e) => setEnvs(Array.isArray(e) ? e : []));
|
|
110
|
+
}, []);
|
|
111
|
+
return (_jsxs("div", { className: "list full settings-screen", children: [_jsx("h2", { className: "screen-title", children: "Settings" }), _jsxs("section", { className: "settings-section", children: [_jsx("h3", { children: "General" }), _jsxs("div", { className: "setting-field", children: [_jsx("span", { className: "setting-label", children: "Name" }), _jsx("span", { className: "setting-value", children: project?.name ?? '—' })] }), _jsxs("div", { className: "setting-field", children: [_jsx("span", { className: "setting-label", children: "Slug" }), _jsx("span", { className: "setting-value mono", children: project?.slug ?? '—' })] }), _jsxs("div", { className: "setting-field", children: [_jsx("span", { className: "setting-label", children: "Platform" }), _jsx("span", { className: "setting-value", children: project?.platform ?? '—' })] })] }), _jsxs("section", { className: "settings-section", children: [_jsx("h3", { children: "Client Keys (DSN)" }), keys.length === 0 ? _jsx("div", { className: "empty", children: "No keys" }) :
|
|
112
|
+
keys.map((k) => (_jsxs("div", { className: "dsn-row", children: [_jsx("span", { className: "dsn-name", children: k.name }), _jsx("span", { className: "dsn-public mono", children: k.dsn?.public }), _jsx("span", { className: `pill ${k.isActive === false ? 'status-ignored' : 'status-resolved'}`, children: k.isActive === false ? 'revoked' : 'active' })] }, k.id)))] }), _jsxs("section", { className: "settings-section", children: [_jsx("h3", { children: "Environments" }), envs.length === 0 ? _jsx("div", { className: "empty", children: "No environments" }) :
|
|
113
|
+
_jsx("div", { className: "env-list", children: envs.map((e) => _jsx("span", { className: "env-item pill", children: e.name }, e.id)) })] })] }));
|
|
114
|
+
}
|
|
115
|
+
// Presentational (data-in, no fetch): the Dashboards screen rendered over a given list of
|
|
116
|
+
// dashboards. Exported so verify() can render it offline over seeded twin state with
|
|
117
|
+
// react-dom/server and assert one `dashboard-row` per seeded dashboard (+ its widgets).
|
|
118
|
+
export function DashboardsView({ dashboards }) {
|
|
119
|
+
return (_jsxs("div", { className: "list full dashboards-screen", children: [_jsx("h2", { className: "screen-title", children: "Dashboards" }), _jsxs("div", { className: "dashboards-sub", children: ["Custom dashboards & widgets for ", MIRROR_DEFAULT_ORG] }), dashboards.length === 0 ? _jsx("div", { className: "empty", children: "No dashboards" }) :
|
|
120
|
+
dashboards.map((d) => (_jsxs("div", { className: "dashboard-row", children: [_jsx("span", { className: "dashboard-title", children: d.title }), _jsxs("div", { className: "dashboard-widgets", children: [(Array.isArray(d.widgets) ? d.widgets : []).map((w, i) => (_jsxs("div", { className: "widget-card", children: [_jsx("span", { className: "widget-title", children: w.title }), _jsx("span", { className: "widget-type pill", children: w.displayType })] }, i))), (!Array.isArray(d.widgets) || d.widgets.length === 0) && _jsx("span", { className: "empty", children: "No widgets" })] })] }, d.id)))] }));
|
|
121
|
+
}
|
|
122
|
+
function DashboardsScreen() {
|
|
123
|
+
const [dashboards, setDashboards] = useState([]);
|
|
124
|
+
useEffect(() => { api(`/organizations/${MIRROR_DEFAULT_ORG}/dashboards/`).then((d) => setDashboards(Array.isArray(d) ? d : [])); }, []);
|
|
125
|
+
return _jsx(DashboardsView, { dashboards: dashboards });
|
|
126
|
+
}
|
|
127
|
+
// Presentational (data-in, no fetch): the Discover screen rendered over a given query-builder
|
|
128
|
+
// state + list of saved queries. Exported so verify() can render it offline over seeded twin
|
|
129
|
+
// state and assert one `saved-query-row` per seeded saved query. The builder is purely local
|
|
130
|
+
// editor state in the live screen (column/query inputs); we accept it as props so the render is
|
|
131
|
+
// deterministic and self-contained.
|
|
132
|
+
export function DiscoverView({ saved, fields = 'title, count(), last_seen()', query = 'event.type:error', onFields, onQuery }) {
|
|
133
|
+
const columns = fields.split(',').map((c) => c.trim()).filter(Boolean);
|
|
134
|
+
return (_jsxs("div", { className: "list full discover-screen", children: [_jsx("h2", { className: "screen-title", children: "Discover" }), _jsxs("div", { className: "discover-builder", children: [_jsxs("div", { className: "discover-field", children: [_jsx("label", { className: "discover-label", children: "Columns" }), _jsx("input", { className: "discover-columns", value: fields, onChange: onFields ? (e) => onFields(e.target.value) : undefined, placeholder: "title, count(), last_seen()", readOnly: !onFields })] }), _jsxs("div", { className: "discover-field", children: [_jsx("label", { className: "discover-label", children: "Query" }), _jsx("input", { className: "discover-query", value: query, onChange: onQuery ? (e) => onQuery(e.target.value) : undefined, placeholder: "event.type:error", readOnly: !onQuery })] }), _jsx("div", { className: "discover-columns-preview", children: columns.map((c) => _jsx("span", { className: "discover-column-chip mono", children: c }, c)) })] }), _jsx("div", { className: "discover-results-note empty", children: "Live query execution requires the data-plane query engine (not built yet) \u2014 the query builder config is faithful and saved queries persist." }), _jsx("h3", { children: "Saved Queries" }), saved.length === 0 ? _jsx("div", { className: "empty", children: "No saved queries" }) :
|
|
135
|
+
saved.map((q) => (_jsxs("div", { className: "saved-query-row", children: [_jsx("span", { className: "saved-query-name", children: q.name }), _jsx("span", { className: "saved-query-fields mono", children: (Array.isArray(q.fields) ? q.fields : []).join(', ') })] }, q.id)))] }));
|
|
136
|
+
}
|
|
137
|
+
function DiscoverScreen() {
|
|
138
|
+
const [saved, setSaved] = useState([]);
|
|
139
|
+
const [fields, setFields] = useState('title, count(), last_seen()');
|
|
140
|
+
const [query, setQuery] = useState('event.type:error');
|
|
141
|
+
useEffect(() => { api(`/organizations/${MIRROR_DEFAULT_ORG}/discover/saved/`).then((q) => setSaved(Array.isArray(q) ? q : [])); }, []);
|
|
142
|
+
return _jsx(DiscoverView, { saved: saved, fields: fields, query: query, onFields: setFields, onQuery: setQuery });
|
|
143
|
+
}
|
|
144
|
+
function App() {
|
|
145
|
+
const [nav, setNav] = useState('issues');
|
|
146
|
+
return (_jsxs("div", { className: "app", children: [_jsxs("header", { className: "topbar", children: [_jsxs("span", { className: "brand", children: ["Sentry ", _jsx("span", { className: "badge", children: "twin" })] }), _jsx("span", { className: "org mono", children: MIRROR_DEFAULT_ORG })] }), _jsxs("div", { className: "body", children: [_jsx("nav", { className: "sidenav", children: NAV.map((n) => (_jsx("button", { className: `nav-item ${nav === n.key ? 'active' : ''}`, onClick: () => setNav(n.key), children: n.label }, n.key))) }), _jsxs("main", { className: "content", children: [nav === 'issues' && _jsx(IssuesScreen, {}), nav === 'dashboards' && _jsx(DashboardsScreen, {}), nav === 'discover' && _jsx(DiscoverScreen, {}), nav === 'releases' && _jsx(ReleasesScreen, {}), nav === 'projects' && _jsx(ProjectsScreen, {}), nav === 'alerts' && _jsx(AlertsScreen, {}), nav === 'settings' && _jsx(SettingsScreen, {})] })] })] }));
|
|
147
|
+
}
|
|
148
|
+
// Browser-only mount. Guarded so the presentational components above (DashboardsView,
|
|
149
|
+
// DiscoverView, …) can be imported and renderToStaticMarkup'd in a DOM-less test/SSR
|
|
150
|
+
// environment without this top-level `document` access throwing. The browser bundle still
|
|
151
|
+
// mounts exactly as before.
|
|
152
|
+
if (typeof document !== 'undefined') {
|
|
153
|
+
const rootEl = document.getElementById('root');
|
|
154
|
+
if (rootEl)
|
|
155
|
+
createRoot(rootEl).render(_jsx(App, {}));
|
|
156
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// Sentry UI mirror — React/TSX client (Bun-bundled). A Sentry-dashboard-style view:
|
|
2
|
+
// top bar + left nav + an Issues stream (grouped errors with count / last-seen / level /
|
|
3
|
+
// status), an Issue detail (latest event stacktrace + tags), and Releases — all backed by
|
|
4
|
+
// REAL twin state via the twin's own Web API (/api/0/...). API↔UI parity.
|
|
5
|
+
//
|
|
6
|
+
// Pure format/level helpers live in ../src/sentry-mirror-ui.ts (Bun tree-shakes the
|
|
7
|
+
// server-only exports out of this browser bundle).
|
|
8
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
9
|
+
import { createRoot } from 'react-dom/client';
|
|
10
|
+
import {
|
|
11
|
+
levelTone, statusTone, formatCount, timeAgo, latestStacktrace, eventTags,
|
|
12
|
+
MIRROR_DEFAULT_ORG, MIRROR_DEFAULT_PROJECT,
|
|
13
|
+
type IssueRow, type StackFrame,
|
|
14
|
+
} from '../src/sentry-mirror-ui.ts';
|
|
15
|
+
|
|
16
|
+
// WHERE THIS MIRROR LIVES. Served at a vendor root the base is '' (fetches are root-relative, as
|
|
17
|
+
// before); served under a path prefix with a <base> tag every read and write resolves inside that
|
|
18
|
+
// prefix instead of escaping it.
|
|
19
|
+
const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
|
|
20
|
+
|
|
21
|
+
type Nav = 'issues' | 'dashboards' | 'discover' | 'releases' | 'projects' | 'alerts' | 'settings';
|
|
22
|
+
|
|
23
|
+
const NAV: Array<{ key: Nav; label: string }> = [
|
|
24
|
+
{ key: 'issues', label: 'Issues' },
|
|
25
|
+
{ key: 'dashboards', label: 'Dashboards' },
|
|
26
|
+
{ key: 'discover', label: 'Discover' },
|
|
27
|
+
{ key: 'releases', label: 'Releases' },
|
|
28
|
+
{ key: 'projects', label: 'Projects' },
|
|
29
|
+
{ key: 'alerts', label: 'Alerts' },
|
|
30
|
+
{ key: 'settings', label: 'Settings' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
async function api(path: string): Promise<any> {
|
|
34
|
+
const res = await fetch(`${WIRE_BASE}/api/0${path}`);
|
|
35
|
+
try { return await res.json(); } catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function LevelPill({ level }: { level: any }) {
|
|
39
|
+
return <span className={`pill level-${levelTone(level)}`}>{String(level ?? '').toUpperCase() || '—'}</span>;
|
|
40
|
+
}
|
|
41
|
+
function StatusPill({ status }: { status: any }) {
|
|
42
|
+
return <span className={`pill status-${statusTone(status)}`}>{String(status ?? '').replace(/_/g, ' ') || '—'}</span>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function IssueRowView({ issue, selected, onSelect }: { issue: IssueRow; selected: boolean; onSelect: () => void }) {
|
|
46
|
+
return (
|
|
47
|
+
<button className={`issue-row ${selected ? 'selected' : ''}`} onClick={onSelect}>
|
|
48
|
+
<div className="issue-main">
|
|
49
|
+
<div className="issue-title">{issue.title}</div>
|
|
50
|
+
<div className="issue-culprit mono">{issue.culprit || issue.shortId}</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div className="issue-meta">
|
|
53
|
+
<LevelPill level={issue.level} />
|
|
54
|
+
<StatusPill status={issue.status} />
|
|
55
|
+
<span className="issue-count" title="event count">{formatCount(issue.count)} events</span>
|
|
56
|
+
<span className="issue-lastseen">{timeAgo(issue.lastSeen)}</span>
|
|
57
|
+
</div>
|
|
58
|
+
</button>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function Stacktrace({ frames }: { frames: StackFrame[] }) {
|
|
63
|
+
if (frames.length === 0) return <div className="empty">No stacktrace</div>;
|
|
64
|
+
return (
|
|
65
|
+
<div className="stacktrace">
|
|
66
|
+
{frames.map((f, i) => (
|
|
67
|
+
<div className="frame mono" key={i}>
|
|
68
|
+
<span className="frame-fn">{f.function ?? '?'}</span>
|
|
69
|
+
<span className="frame-file">{f.filename ?? f.module ?? ''}{f.lineno ? `:${f.lineno}` : ''}</span>
|
|
70
|
+
</div>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function IssueDetail({ issue }: { issue: IssueRow }) {
|
|
77
|
+
const [event, setEvent] = useState<any>(null);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
let live = true;
|
|
80
|
+
api(`/issues/${issue.id}/events/latest/`).then((e) => { if (live) setEvent(e); });
|
|
81
|
+
return () => { live = false; };
|
|
82
|
+
}, [issue.id]);
|
|
83
|
+
const frames = useMemo(() => latestStacktrace(event), [event]);
|
|
84
|
+
const tags = useMemo(() => eventTags(event), [event]);
|
|
85
|
+
return (
|
|
86
|
+
<div className="issue-detail">
|
|
87
|
+
<h2 className="detail-title">{issue.title}</h2>
|
|
88
|
+
<div className="detail-sub mono">{issue.culprit}</div>
|
|
89
|
+
<div className="detail-stats">
|
|
90
|
+
<div><span className="stat-label">Events</span><span className="stat-value">{formatCount(issue.count)}</span></div>
|
|
91
|
+
<div><span className="stat-label">First seen</span><span className="stat-value">{timeAgo(issue.firstSeen)}</span></div>
|
|
92
|
+
<div><span className="stat-label">Last seen</span><span className="stat-value">{timeAgo(issue.lastSeen)}</span></div>
|
|
93
|
+
<div><span className="stat-label">Status</span><StatusPill status={issue.status} /></div>
|
|
94
|
+
</div>
|
|
95
|
+
<h3>Latest Event Stacktrace</h3>
|
|
96
|
+
<Stacktrace frames={frames} />
|
|
97
|
+
<h3>Tags</h3>
|
|
98
|
+
{tags.length === 0 ? <div className="empty">No tags</div> : (
|
|
99
|
+
<div className="tags">{tags.map((t) => <span className="tag mono" key={t.key}>{t.key}: {t.value}</span>)}</div>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function IssuesScreen() {
|
|
106
|
+
const [issues, setIssues] = useState<IssueRow[]>([]);
|
|
107
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
108
|
+
const [query, setQuery] = useState('is:unresolved');
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
api(`/projects/${MIRROR_DEFAULT_ORG}/${MIRROR_DEFAULT_PROJECT}/issues/?query=${encodeURIComponent(query)}`)
|
|
111
|
+
.then((rows) => setIssues(Array.isArray(rows) ? rows : []));
|
|
112
|
+
}, [query]);
|
|
113
|
+
const current = issues.find((i) => i.id === selected) ?? issues[0];
|
|
114
|
+
return (
|
|
115
|
+
<div className="split">
|
|
116
|
+
<div className="list">
|
|
117
|
+
<div className="search-bar">
|
|
118
|
+
<input className="search-input" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="is:unresolved level:error ..." />
|
|
119
|
+
</div>
|
|
120
|
+
{issues.length === 0 ? <div className="empty">No issues</div> :
|
|
121
|
+
issues.map((i) => <IssueRowView key={i.id} issue={i} selected={current?.id === i.id} onSelect={() => setSelected(i.id)} />)}
|
|
122
|
+
</div>
|
|
123
|
+
<div className="detail">{current ? <IssueDetail issue={current} /> : <div className="empty">Select an issue</div>}</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Presentational (data-in, no fetch): the Releases screen rendered over a given list of
|
|
129
|
+
// releases. Exported so verify() can render it offline over seeded twin state with
|
|
130
|
+
// react-dom/server and assert one `release-row` per seeded release carrying its version.
|
|
131
|
+
export function ReleasesView({ releases }: { releases: any[] }) {
|
|
132
|
+
return (
|
|
133
|
+
<div className="list full">
|
|
134
|
+
<h2 className="screen-title">Releases</h2>
|
|
135
|
+
{releases.length === 0 ? <div className="empty">No releases</div> :
|
|
136
|
+
releases.map((r) => (
|
|
137
|
+
<div className="release-row" key={r.id}>
|
|
138
|
+
<span className="release-version mono">{r.version}</span>
|
|
139
|
+
<span className="release-commits">{r.commitCount ?? 0} commits</span>
|
|
140
|
+
<span className="release-date">{timeAgo(r.dateCreated)}</span>
|
|
141
|
+
</div>
|
|
142
|
+
))}
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function ReleasesScreen() {
|
|
148
|
+
const [releases, setReleases] = useState<any[]>([]);
|
|
149
|
+
useEffect(() => { api(`/organizations/${MIRROR_DEFAULT_ORG}/releases/`).then((r) => setReleases(Array.isArray(r) ? r : [])); }, []);
|
|
150
|
+
return <ReleasesView releases={releases} />;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Presentational (data-in, no fetch): the Projects screen rendered over a given list of
|
|
154
|
+
// projects. Exported so verify() can render it offline over seeded twin state with
|
|
155
|
+
// react-dom/server and assert one `project-row` per seeded project carrying its slug.
|
|
156
|
+
export function ProjectsView({ projects }: { projects: any[] }) {
|
|
157
|
+
return (
|
|
158
|
+
<div className="list full">
|
|
159
|
+
<h2 className="screen-title">Projects</h2>
|
|
160
|
+
{projects.length === 0 ? <div className="empty">No projects</div> :
|
|
161
|
+
projects.map((p) => (
|
|
162
|
+
<div className="project-row" key={p.id}>
|
|
163
|
+
<span className="project-name">{p.name}</span>
|
|
164
|
+
<span className="project-slug mono">{p.slug}</span>
|
|
165
|
+
<span className="project-platform">{p.platform ?? '—'}</span>
|
|
166
|
+
</div>
|
|
167
|
+
))}
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function ProjectsScreen() {
|
|
173
|
+
const [projects, setProjects] = useState<any[]>([]);
|
|
174
|
+
useEffect(() => { api(`/projects/`).then((p) => setProjects(Array.isArray(p) ? p : [])); }, []);
|
|
175
|
+
return <ProjectsView projects={projects} />;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function AlertsScreen() {
|
|
179
|
+
const [rules, setRules] = useState<any[]>([]);
|
|
180
|
+
useEffect(() => { api(`/projects/${MIRROR_DEFAULT_ORG}/${MIRROR_DEFAULT_PROJECT}/rules/`).then((r) => setRules(Array.isArray(r) ? r : [])); }, []);
|
|
181
|
+
return (
|
|
182
|
+
<div className="list full alerts-screen">
|
|
183
|
+
<h2 className="screen-title">Alerts</h2>
|
|
184
|
+
<div className="alerts-sub">Issue alert rules for {MIRROR_DEFAULT_PROJECT}</div>
|
|
185
|
+
{rules.length === 0 ? <div className="empty">No alert rules</div> :
|
|
186
|
+
rules.map((r) => (
|
|
187
|
+
<div className="alert-rule-row" key={r.id}>
|
|
188
|
+
<span className="alert-rule-name">{r.name}</span>
|
|
189
|
+
<span className="alert-rule-triggers">
|
|
190
|
+
{(Array.isArray(r.triggers) ? r.triggers : []).map((t: string) => <span className="alert-trigger pill" key={t}>{t}</span>)}
|
|
191
|
+
</span>
|
|
192
|
+
<span className="alert-rule-endpoints mono">{(Array.isArray(r.endpoints) ? r.endpoints : []).length} endpoint(s)</span>
|
|
193
|
+
</div>
|
|
194
|
+
))}
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function SettingsScreen() {
|
|
200
|
+
const [project, setProject] = useState<any>(null);
|
|
201
|
+
const [keys, setKeys] = useState<any[]>([]);
|
|
202
|
+
const [envs, setEnvs] = useState<any[]>([]);
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
api(`/projects/${MIRROR_DEFAULT_ORG}/${MIRROR_DEFAULT_PROJECT}/`).then(setProject);
|
|
205
|
+
api(`/projects/${MIRROR_DEFAULT_ORG}/${MIRROR_DEFAULT_PROJECT}/keys/`).then((k) => setKeys(Array.isArray(k) ? k : []));
|
|
206
|
+
api(`/projects/${MIRROR_DEFAULT_ORG}/${MIRROR_DEFAULT_PROJECT}/environments/`).then((e) => setEnvs(Array.isArray(e) ? e : []));
|
|
207
|
+
}, []);
|
|
208
|
+
return (
|
|
209
|
+
<div className="list full settings-screen">
|
|
210
|
+
<h2 className="screen-title">Settings</h2>
|
|
211
|
+
<section className="settings-section">
|
|
212
|
+
<h3>General</h3>
|
|
213
|
+
<div className="setting-field"><span className="setting-label">Name</span><span className="setting-value">{project?.name ?? '—'}</span></div>
|
|
214
|
+
<div className="setting-field"><span className="setting-label">Slug</span><span className="setting-value mono">{project?.slug ?? '—'}</span></div>
|
|
215
|
+
<div className="setting-field"><span className="setting-label">Platform</span><span className="setting-value">{project?.platform ?? '—'}</span></div>
|
|
216
|
+
</section>
|
|
217
|
+
<section className="settings-section">
|
|
218
|
+
<h3>Client Keys (DSN)</h3>
|
|
219
|
+
{keys.length === 0 ? <div className="empty">No keys</div> :
|
|
220
|
+
keys.map((k) => (
|
|
221
|
+
<div className="dsn-row" key={k.id}>
|
|
222
|
+
<span className="dsn-name">{k.name}</span>
|
|
223
|
+
<span className="dsn-public mono">{k.dsn?.public}</span>
|
|
224
|
+
<span className={`pill ${k.isActive === false ? 'status-ignored' : 'status-resolved'}`}>{k.isActive === false ? 'revoked' : 'active'}</span>
|
|
225
|
+
</div>
|
|
226
|
+
))}
|
|
227
|
+
</section>
|
|
228
|
+
<section className="settings-section">
|
|
229
|
+
<h3>Environments</h3>
|
|
230
|
+
{envs.length === 0 ? <div className="empty">No environments</div> :
|
|
231
|
+
<div className="env-list">{envs.map((e) => <span className="env-item pill" key={e.id}>{e.name}</span>)}</div>}
|
|
232
|
+
</section>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Presentational (data-in, no fetch): the Dashboards screen rendered over a given list of
|
|
238
|
+
// dashboards. Exported so verify() can render it offline over seeded twin state with
|
|
239
|
+
// react-dom/server and assert one `dashboard-row` per seeded dashboard (+ its widgets).
|
|
240
|
+
export function DashboardsView({ dashboards }: { dashboards: any[] }) {
|
|
241
|
+
return (
|
|
242
|
+
<div className="list full dashboards-screen">
|
|
243
|
+
<h2 className="screen-title">Dashboards</h2>
|
|
244
|
+
<div className="dashboards-sub">Custom dashboards & widgets for {MIRROR_DEFAULT_ORG}</div>
|
|
245
|
+
{dashboards.length === 0 ? <div className="empty">No dashboards</div> :
|
|
246
|
+
dashboards.map((d) => (
|
|
247
|
+
<div className="dashboard-row" key={d.id}>
|
|
248
|
+
<span className="dashboard-title">{d.title}</span>
|
|
249
|
+
<div className="dashboard-widgets">
|
|
250
|
+
{(Array.isArray(d.widgets) ? d.widgets : []).map((w: any, i: number) => (
|
|
251
|
+
<div className="widget-card" key={i}>
|
|
252
|
+
<span className="widget-title">{w.title}</span>
|
|
253
|
+
<span className="widget-type pill">{w.displayType}</span>
|
|
254
|
+
</div>
|
|
255
|
+
))}
|
|
256
|
+
{(!Array.isArray(d.widgets) || d.widgets.length === 0) && <span className="empty">No widgets</span>}
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
))}
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function DashboardsScreen() {
|
|
265
|
+
const [dashboards, setDashboards] = useState<any[]>([]);
|
|
266
|
+
useEffect(() => { api(`/organizations/${MIRROR_DEFAULT_ORG}/dashboards/`).then((d) => setDashboards(Array.isArray(d) ? d : [])); }, []);
|
|
267
|
+
return <DashboardsView dashboards={dashboards} />;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Presentational (data-in, no fetch): the Discover screen rendered over a given query-builder
|
|
271
|
+
// state + list of saved queries. Exported so verify() can render it offline over seeded twin
|
|
272
|
+
// state and assert one `saved-query-row` per seeded saved query. The builder is purely local
|
|
273
|
+
// editor state in the live screen (column/query inputs); we accept it as props so the render is
|
|
274
|
+
// deterministic and self-contained.
|
|
275
|
+
export function DiscoverView({ saved, fields = 'title, count(), last_seen()', query = 'event.type:error', onFields, onQuery }: { saved: any[]; fields?: string; query?: string; onFields?: (v: string) => void; onQuery?: (v: string) => void }) {
|
|
276
|
+
const columns = fields.split(',').map((c) => c.trim()).filter(Boolean);
|
|
277
|
+
return (
|
|
278
|
+
<div className="list full discover-screen">
|
|
279
|
+
<h2 className="screen-title">Discover</h2>
|
|
280
|
+
<div className="discover-builder">
|
|
281
|
+
<div className="discover-field">
|
|
282
|
+
<label className="discover-label">Columns</label>
|
|
283
|
+
<input className="discover-columns" value={fields} onChange={onFields ? (e) => onFields(e.target.value) : undefined} placeholder="title, count(), last_seen()" readOnly={!onFields} />
|
|
284
|
+
</div>
|
|
285
|
+
<div className="discover-field">
|
|
286
|
+
<label className="discover-label">Query</label>
|
|
287
|
+
<input className="discover-query" value={query} onChange={onQuery ? (e) => onQuery(e.target.value) : undefined} placeholder="event.type:error" readOnly={!onQuery} />
|
|
288
|
+
</div>
|
|
289
|
+
<div className="discover-columns-preview">
|
|
290
|
+
{columns.map((c) => <span className="discover-column-chip mono" key={c}>{c}</span>)}
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
<div className="discover-results-note empty">
|
|
294
|
+
Live query execution requires the data-plane query engine (not built yet) — the query builder
|
|
295
|
+
config is faithful and saved queries persist.
|
|
296
|
+
</div>
|
|
297
|
+
<h3>Saved Queries</h3>
|
|
298
|
+
{saved.length === 0 ? <div className="empty">No saved queries</div> :
|
|
299
|
+
saved.map((q) => (
|
|
300
|
+
<div className="saved-query-row" key={q.id}>
|
|
301
|
+
<span className="saved-query-name">{q.name}</span>
|
|
302
|
+
<span className="saved-query-fields mono">{(Array.isArray(q.fields) ? q.fields : []).join(', ')}</span>
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function DiscoverScreen() {
|
|
310
|
+
const [saved, setSaved] = useState<any[]>([]);
|
|
311
|
+
const [fields, setFields] = useState('title, count(), last_seen()');
|
|
312
|
+
const [query, setQuery] = useState('event.type:error');
|
|
313
|
+
useEffect(() => { api(`/organizations/${MIRROR_DEFAULT_ORG}/discover/saved/`).then((q) => setSaved(Array.isArray(q) ? q : [])); }, []);
|
|
314
|
+
return <DiscoverView saved={saved} fields={fields} query={query} onFields={setFields} onQuery={setQuery} />;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function App() {
|
|
318
|
+
const [nav, setNav] = useState<Nav>('issues');
|
|
319
|
+
return (
|
|
320
|
+
<div className="app">
|
|
321
|
+
<header className="topbar">
|
|
322
|
+
<span className="brand">Sentry <span className="badge">twin</span></span>
|
|
323
|
+
<span className="org mono">{MIRROR_DEFAULT_ORG}</span>
|
|
324
|
+
</header>
|
|
325
|
+
<div className="body">
|
|
326
|
+
<nav className="sidenav">
|
|
327
|
+
{NAV.map((n) => (
|
|
328
|
+
<button key={n.key} className={`nav-item ${nav === n.key ? 'active' : ''}`} onClick={() => setNav(n.key)}>{n.label}</button>
|
|
329
|
+
))}
|
|
330
|
+
</nav>
|
|
331
|
+
<main className="content">
|
|
332
|
+
{nav === 'issues' && <IssuesScreen />}
|
|
333
|
+
{nav === 'dashboards' && <DashboardsScreen />}
|
|
334
|
+
{nav === 'discover' && <DiscoverScreen />}
|
|
335
|
+
{nav === 'releases' && <ReleasesScreen />}
|
|
336
|
+
{nav === 'projects' && <ProjectsScreen />}
|
|
337
|
+
{nav === 'alerts' && <AlertsScreen />}
|
|
338
|
+
{nav === 'settings' && <SettingsScreen />}
|
|
339
|
+
</main>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Browser-only mount. Guarded so the presentational components above (DashboardsView,
|
|
346
|
+
// DiscoverView, …) can be imported and renderToStaticMarkup'd in a DOM-less test/SSR
|
|
347
|
+
// environment without this top-level `document` access throwing. The browser bundle still
|
|
348
|
+
// mounts exactly as before.
|
|
349
|
+
if (typeof document !== 'undefined') {
|
|
350
|
+
const rootEl = document.getElementById('root');
|
|
351
|
+
if (rootEl) createRoot(rootEl).render(<App />);
|
|
352
|
+
}
|