deploy.sh 2.0.0 → 3.0.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/.claude/settings.local.json +36 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
- package/.github/ISSUE_TEMPLATE/config.yml +5 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +56 -0
- package/.github/workflows/ci.yml +15 -34
- package/.github/workflows/pages.yml +48 -0
- package/.oxfmtrc.json +7 -0
- package/.oxlintrc.json +11 -0
- package/LICENSE +183 -183
- package/README.md +99 -10
- package/app/actions/deployments.ts +82 -0
- package/app/actions/metrics.ts +13 -0
- package/app/root.tsx +60 -0
- package/app/routes/dashboard/detail/history.tsx +73 -0
- package/app/routes/dashboard/detail/layout.tsx +125 -0
- package/app/routes/dashboard/detail/logs.tsx +85 -0
- package/app/routes/dashboard/detail/overview.tsx +119 -0
- package/app/routes/dashboard/detail/requests.tsx +163 -0
- package/app/routes/dashboard/detail/resources.tsx +268 -0
- package/app/routes/dashboard/detail/shared.tsx +59 -0
- package/app/routes/dashboard/index.tsx +360 -0
- package/app/routes/dashboard/layout.tsx +30 -0
- package/app/routes/docs/architecture.tsx +155 -0
- package/app/routes/docs/cli.tsx +122 -0
- package/app/routes/docs/deploying.tsx +105 -0
- package/app/routes/docs/index.tsx +104 -0
- package/app/routes/docs/layout.tsx +58 -0
- package/app/routes/home.tsx +134 -0
- package/app/routes/root.client.tsx +46 -0
- package/app/routes.ts +21 -0
- package/app/styles.css +15 -0
- package/app/theme.css +134 -0
- package/bin/deploy.js +362 -138
- package/docs-site/404.html +33 -0
- package/docs-site/home.tsx +130 -0
- package/docs-site/index.html +35 -0
- package/docs-site/layout.tsx +57 -0
- package/docs-site/main.tsx +41 -0
- package/docs-site/shell.tsx +34 -0
- package/docs-site/styles.css +4 -0
- package/drizzle.config.js +8 -0
- package/examples/docker/Dockerfile +5 -5
- package/examples/docker/server.js +18 -0
- package/examples/node/package.json +3 -11
- package/examples/node/pnpm-lock.yaml +9 -0
- package/examples/node/server.js +12 -0
- package/examples/static/index.html +41 -15
- package/package.json +40 -64
- package/public/favicon.ico +0 -0
- package/react-router-vite/entry.browser.tsx +49 -0
- package/react-router-vite/entry.rsc.single.tsx +7 -0
- package/react-router-vite/entry.rsc.tsx +36 -0
- package/react-router-vite/entry.ssr.tsx +29 -0
- package/react-router-vite/plugin.ts +114 -0
- package/react-router-vite/types.d.ts +11 -0
- package/react-router.config.ts +5 -0
- package/server/api.test.ts +344 -0
- package/server/api.ts +445 -0
- package/server/docker.ts +268 -0
- package/server/index.ts +17 -0
- package/server/metrics-collector.ts +29 -0
- package/server/schema.ts +56 -0
- package/server/store.test.ts +278 -0
- package/server/store.ts +398 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +45 -0
- package/vite.docs.config.ts +31 -0
- package/.eslintignore +0 -6
- package/.eslintrc +0 -12
- package/.husky/pre-commit +0 -5
- package/.prettierrc +0 -0
- package/.release-it.json +0 -5
- package/CHANGELOG.md +0 -56
- package/__tests__/fixtures/unknown/.gitkeep +0 -0
- package/__tests__/lib/classifier.test.js +0 -49
- package/__tests__/lib/helpers/util.test.js +0 -57
- package/bin/deploy-delete.js +0 -14
- package/bin/deploy-deploy.js +0 -36
- package/bin/deploy-list.js +0 -40
- package/bin/deploy-login.js +0 -43
- package/bin/deploy-logout.js +0 -16
- package/bin/deploy-logs.js +0 -26
- package/bin/deploy-open.js +0 -26
- package/bin/deploy-register.js +0 -45
- package/bin/deploy-server.js +0 -11
- package/bin/deploy-whoami.js +0 -14
- package/examples/docker/index.js +0 -12
- package/examples/node/index.js +0 -8
- package/examples/static/main.css +0 -9
- package/examples/static/out.gifcd +0 -0
- package/generate-docs.js +0 -55
- package/index.js +0 -69
- package/jsdoc.json +0 -27
- package/lib/classifier.js +0 -63
- package/lib/deploy.js +0 -70
- package/lib/helpers/cli.js +0 -262
- package/lib/helpers/util.js +0 -140
- package/lib/models/deployment.js +0 -474
- package/lib/models/request.js +0 -101
- package/lib/models/user.js +0 -147
- package/lib/server.js +0 -211
- package/lib/static/not-found.html +0 -30
- package/lib/static/page-could-not-load.html +0 -30
- package/lib/static/static-server.js +0 -70
- package/website/README.md +0 -41
- package/website/babel.config.js +0 -3
- package/website/docs/api/_category_.yml +0 -1
- package/website/docs/api/lib/classifier.js.md +0 -11
- package/website/docs/api/lib/deploy.js.md +0 -13
- package/website/docs/api/lib/helpers/cli.js.md +0 -193
- package/website/docs/api/lib/helpers/util.js.md +0 -65
- package/website/docs/api/lib/models/deployment.js.md +0 -171
- package/website/docs/api/lib/models/request.js.md +0 -67
- package/website/docs/api/lib/models/user.js.md +0 -92
- package/website/docs/api/lib/server.js.md +0 -0
- package/website/docs/api/lib/static/static-server.js.md +0 -0
- package/website/docs/intro.md +0 -57
- package/website/docusaurus.config.js +0 -82
- package/website/package-lock.json +0 -25218
- package/website/package.json +0 -39
- package/website/sidebars.js +0 -31
- package/website/src/components/HomepageFeatures/index.js +0 -79
- package/website/src/components/HomepageFeatures/styles.module.css +0 -11
- package/website/src/css/custom.css +0 -39
- package/website/src/pages/index.js +0 -57
- package/website/src/pages/index.module.css +0 -23
- package/website/static/.nojekyll +0 -0
- package/website/static/example.gif +0 -0
- package/website/static/example.mov +0 -0
- package/website/static/img/favicon.ico +0 -0
- package/website/static/img/intro/deploy.png +0 -0
- package/website/static/img/intro/logs.png +0 -0
- package/website/static/img/logo.png +0 -0
- package/website/static/img/logo.pxm +0 -0
- package/website/static/img/logo@2x.png +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { useOutletContext } from 'react-router';
|
|
5
|
+
import { fetchDeployHistory as serverFetchHistory } from '../../../actions/deployments';
|
|
6
|
+
import { getAuth } from './shared';
|
|
7
|
+
import type { DetailContext } from './shared';
|
|
8
|
+
|
|
9
|
+
interface HistoryEvent {
|
|
10
|
+
action: string;
|
|
11
|
+
username: string;
|
|
12
|
+
timestamp: string;
|
|
13
|
+
type?: string;
|
|
14
|
+
port?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function Component() {
|
|
18
|
+
const { deployment } = useOutletContext<DetailContext>();
|
|
19
|
+
const name = deployment.name;
|
|
20
|
+
const [events, setEvents] = useState<HistoryEvent[]>([]);
|
|
21
|
+
const [loading, setLoading] = useState(true);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const auth = getAuth();
|
|
25
|
+
if (!auth) return;
|
|
26
|
+
serverFetchHistory(auth.username, auth.token, name)
|
|
27
|
+
.then((data) => setEvents(data as HistoryEvent[]))
|
|
28
|
+
.finally(() => setLoading(false));
|
|
29
|
+
}, [name]);
|
|
30
|
+
|
|
31
|
+
if (loading) {
|
|
32
|
+
return <div className="text-sm text-text-tertiary text-center py-8">Loading...</div>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (events.length === 0) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="card p-6 text-center text-sm text-text-secondary">
|
|
38
|
+
No history recorded yet. History tracking starts from this version.
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="card overflow-hidden">
|
|
45
|
+
<div className="divide-y divide-border">
|
|
46
|
+
{[...events].reverse().map((e, i) => (
|
|
47
|
+
<div key={i} className="px-4 py-3 flex items-center gap-4">
|
|
48
|
+
<div
|
|
49
|
+
className={`w-2 h-2 rounded-full shrink-0 ${
|
|
50
|
+
e.action === 'deploy'
|
|
51
|
+
? 'bg-success'
|
|
52
|
+
: e.action === 'restart'
|
|
53
|
+
? 'bg-warning'
|
|
54
|
+
: 'bg-danger'
|
|
55
|
+
}`}
|
|
56
|
+
/>
|
|
57
|
+
<div className="flex-1 min-w-0">
|
|
58
|
+
<p className="text-sm font-medium capitalize">{e.action}</p>
|
|
59
|
+
<p className="text-xs text-text-secondary">
|
|
60
|
+
by {e.username}
|
|
61
|
+
{e.type && ` · ${e.type}`}
|
|
62
|
+
{e.port && ` · port ${e.port}`}
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
<time className="text-xs text-text-tertiary shrink-0">
|
|
66
|
+
{new Date(e.timestamp).toLocaleString()}
|
|
67
|
+
</time>
|
|
68
|
+
</div>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import { useParams, Link, Outlet, useLocation } from 'react-router';
|
|
5
|
+
import {
|
|
6
|
+
fetchDeployment as serverFetchDeployment,
|
|
7
|
+
fetchContainerInspect as serverFetchInspect,
|
|
8
|
+
} from '../../../actions/deployments';
|
|
9
|
+
import { getAuth, StatusBadge } from './shared';
|
|
10
|
+
import type { Deployment, ContainerInfo, DetailContext } from './shared';
|
|
11
|
+
|
|
12
|
+
type Tab = 'overview' | 'logs' | 'requests' | 'resources' | 'history';
|
|
13
|
+
|
|
14
|
+
const tabs: { key: Tab; label: string; path: string }[] = [
|
|
15
|
+
{ key: 'overview', label: 'Overview', path: '' },
|
|
16
|
+
{ key: 'logs', label: 'Logs', path: 'logs' },
|
|
17
|
+
{ key: 'requests', label: 'Requests', path: 'requests' },
|
|
18
|
+
{ key: 'resources', label: 'Resources', path: 'resources' },
|
|
19
|
+
{ key: 'history', label: 'History', path: 'history' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function getActiveTab(pathname: string, name: string): Tab {
|
|
23
|
+
const base = `/dashboard/${name}`;
|
|
24
|
+
const suffix = pathname.slice(base.length).replace(/^\//, '');
|
|
25
|
+
const match = tabs.find((t) => t.path === suffix);
|
|
26
|
+
return match?.key ?? 'overview';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function Component() {
|
|
30
|
+
const { name } = useParams();
|
|
31
|
+
const location = useLocation();
|
|
32
|
+
const [deployment, setDeployment] = useState<Deployment | null>(null);
|
|
33
|
+
const [inspect, setInspect] = useState<ContainerInfo | null>(null);
|
|
34
|
+
const [loading, setLoading] = useState(true);
|
|
35
|
+
const [error, setError] = useState('');
|
|
36
|
+
|
|
37
|
+
const activeTab = getActiveTab(location.pathname, name!);
|
|
38
|
+
|
|
39
|
+
const fetchDeployment = useCallback(async () => {
|
|
40
|
+
try {
|
|
41
|
+
const auth = getAuth();
|
|
42
|
+
if (!auth) return;
|
|
43
|
+
const data = await serverFetchDeployment(auth.username, auth.token, name!);
|
|
44
|
+
setDeployment(data as Deployment);
|
|
45
|
+
setError('');
|
|
46
|
+
} catch (err) {
|
|
47
|
+
setError((err as Error).message);
|
|
48
|
+
} finally {
|
|
49
|
+
setLoading(false);
|
|
50
|
+
}
|
|
51
|
+
}, [name]);
|
|
52
|
+
|
|
53
|
+
const fetchInspect = useCallback(async () => {
|
|
54
|
+
try {
|
|
55
|
+
const auth = getAuth();
|
|
56
|
+
if (!auth) return;
|
|
57
|
+
const data = await serverFetchInspect(auth.username, auth.token, name!);
|
|
58
|
+
setInspect(data as ContainerInfo);
|
|
59
|
+
} catch {
|
|
60
|
+
// container may not exist
|
|
61
|
+
}
|
|
62
|
+
}, [name]);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
fetchDeployment();
|
|
66
|
+
fetchInspect();
|
|
67
|
+
}, [fetchDeployment, fetchInspect]);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const interval = setInterval(() => {
|
|
71
|
+
fetchDeployment();
|
|
72
|
+
if (activeTab === 'overview') fetchInspect();
|
|
73
|
+
}, 5000);
|
|
74
|
+
return () => clearInterval(interval);
|
|
75
|
+
}, [fetchDeployment, fetchInspect, activeTab]);
|
|
76
|
+
|
|
77
|
+
if (loading) {
|
|
78
|
+
return <div className="text-sm text-text-tertiary py-12 text-center">Loading...</div>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (error || !deployment) {
|
|
82
|
+
return (
|
|
83
|
+
<div>
|
|
84
|
+
<Link to="/dashboard" className="text-sm text-accent hover:text-accent-hover mb-4 block">
|
|
85
|
+
← Back to deployments
|
|
86
|
+
</Link>
|
|
87
|
+
<div className="card p-6 text-center text-sm text-danger">
|
|
88
|
+
{error || 'Deployment not found'}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const context: DetailContext = { deployment, inspect, fetchDeployment, fetchInspect };
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div>
|
|
98
|
+
<div className="flex items-center gap-3 mb-6">
|
|
99
|
+
<Link to="/dashboard" className="text-text-tertiary hover:text-text-secondary text-sm">
|
|
100
|
+
←
|
|
101
|
+
</Link>
|
|
102
|
+
<h1 className="text-lg font-semibold">{deployment.name}</h1>
|
|
103
|
+
<StatusBadge status={deployment.status} />
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div className="flex gap-1 border-b border-border mb-6">
|
|
107
|
+
{tabs.map((t) => (
|
|
108
|
+
<Link
|
|
109
|
+
key={t.key}
|
|
110
|
+
to={`/dashboard/${name}${t.path ? `/${t.path}` : ''}`}
|
|
111
|
+
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
|
112
|
+
activeTab === t.key
|
|
113
|
+
? 'border-accent text-text'
|
|
114
|
+
: 'border-transparent text-text-tertiary hover:text-text-secondary'
|
|
115
|
+
}`}
|
|
116
|
+
>
|
|
117
|
+
{t.label}
|
|
118
|
+
</Link>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<Outlet context={context} />
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef } from 'react';
|
|
4
|
+
import { useOutletContext } from 'react-router';
|
|
5
|
+
import { getAuth } from './shared';
|
|
6
|
+
import type { DetailContext } from './shared';
|
|
7
|
+
|
|
8
|
+
export default function Component() {
|
|
9
|
+
const { deployment } = useOutletContext<DetailContext>();
|
|
10
|
+
const name = deployment.name;
|
|
11
|
+
const [logs, setLogs] = useState('');
|
|
12
|
+
const [streaming, setStreaming] = useState(false);
|
|
13
|
+
const containerRef = useRef<HTMLPreElement>(null);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
setStreaming(true);
|
|
17
|
+
setLogs('');
|
|
18
|
+
|
|
19
|
+
const auth = getAuth();
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
|
|
22
|
+
(async () => {
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(`/api/deployments/${name}/logs`, {
|
|
25
|
+
headers: {
|
|
26
|
+
'x-deploy-username': auth?.username || '',
|
|
27
|
+
'x-deploy-token': auth?.token || '',
|
|
28
|
+
},
|
|
29
|
+
signal: controller.signal,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!res.ok || !res.body) {
|
|
33
|
+
setStreaming(false);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const reader = res.body.getReader();
|
|
38
|
+
const decoder = new TextDecoder();
|
|
39
|
+
|
|
40
|
+
while (true) {
|
|
41
|
+
const { done, value } = await reader.read();
|
|
42
|
+
if (done) break;
|
|
43
|
+
setLogs((prev) => prev + decoder.decode(value, { stream: true }));
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// aborted or network error
|
|
47
|
+
}
|
|
48
|
+
setStreaming(false);
|
|
49
|
+
})();
|
|
50
|
+
|
|
51
|
+
return () => {
|
|
52
|
+
controller.abort();
|
|
53
|
+
};
|
|
54
|
+
}, [name]);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (containerRef.current) {
|
|
58
|
+
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
|
59
|
+
}
|
|
60
|
+
}, [logs]);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div>
|
|
64
|
+
<div className="flex items-center justify-between mb-3">
|
|
65
|
+
<h3 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
|
66
|
+
Container Logs
|
|
67
|
+
</h3>
|
|
68
|
+
<div className="flex items-center gap-2">
|
|
69
|
+
{streaming && (
|
|
70
|
+
<span className="flex items-center gap-1.5 text-xs text-success">
|
|
71
|
+
<span className="w-1.5 h-1.5 rounded-full bg-success animate-pulse" />
|
|
72
|
+
Live
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
<pre
|
|
78
|
+
ref={containerRef}
|
|
79
|
+
className="card p-4 text-xs font-mono leading-relaxed text-text-secondary overflow-auto max-h-[500px] whitespace-pre-wrap"
|
|
80
|
+
>
|
|
81
|
+
{logs || (streaming ? 'Waiting for logs...' : 'No logs available.')}
|
|
82
|
+
</pre>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useOutletContext } from 'react-router';
|
|
4
|
+
import { restartDeployment as serverRestart } from '../../../actions/deployments';
|
|
5
|
+
import { appUrl, getAuth, StatusBadge } from './shared';
|
|
6
|
+
import type { DetailContext } from './shared';
|
|
7
|
+
|
|
8
|
+
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="flex items-center justify-between">
|
|
11
|
+
<span className="text-xs text-text-secondary">{label}</span>
|
|
12
|
+
<span className="text-sm">{children}</span>
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatUptime(ms: number) {
|
|
18
|
+
const secs = Math.floor(ms / 1000);
|
|
19
|
+
if (secs < 60) return `${secs}s`;
|
|
20
|
+
const mins = Math.floor(secs / 60);
|
|
21
|
+
if (mins < 60) return `${mins}m ${secs % 60}s`;
|
|
22
|
+
const hours = Math.floor(mins / 60);
|
|
23
|
+
if (hours < 24) return `${hours}h ${mins % 60}m`;
|
|
24
|
+
const days = Math.floor(hours / 24);
|
|
25
|
+
return `${days}d ${hours % 24}h`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function Component() {
|
|
29
|
+
const { deployment, inspect, fetchDeployment, fetchInspect } = useOutletContext<DetailContext>();
|
|
30
|
+
|
|
31
|
+
const started = inspect?.started ? new Date(inspect.started) : null;
|
|
32
|
+
const uptime = started ? formatUptime(Date.now() - started.getTime()) : 'N/A';
|
|
33
|
+
|
|
34
|
+
async function handleRestart() {
|
|
35
|
+
const auth = getAuth();
|
|
36
|
+
if (!auth) return;
|
|
37
|
+
await serverRestart(auth.username, auth.token, deployment.name);
|
|
38
|
+
fetchDeployment();
|
|
39
|
+
fetchInspect();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="space-y-6">
|
|
44
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
45
|
+
<div className="card p-4 space-y-3">
|
|
46
|
+
<h3 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
|
47
|
+
Container
|
|
48
|
+
</h3>
|
|
49
|
+
<InfoRow label="Status">
|
|
50
|
+
<StatusBadge status={deployment.status} />
|
|
51
|
+
</InfoRow>
|
|
52
|
+
<InfoRow label="Uptime">{uptime}</InfoRow>
|
|
53
|
+
<InfoRow label="Restarts">{inspect?.restartCount ?? 'N/A'}</InfoRow>
|
|
54
|
+
<InfoRow label="Image">
|
|
55
|
+
<span className="font-mono text-xs">{inspect?.image ?? 'N/A'}</span>
|
|
56
|
+
</InfoRow>
|
|
57
|
+
<InfoRow label="Container ID">
|
|
58
|
+
<span className="font-mono text-xs">{deployment.containerId?.slice(0, 12)}</span>
|
|
59
|
+
</InfoRow>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div className="card p-4 space-y-3">
|
|
63
|
+
<h3 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
|
64
|
+
Deployment
|
|
65
|
+
</h3>
|
|
66
|
+
<InfoRow label="Name">{deployment.name}</InfoRow>
|
|
67
|
+
<InfoRow label="Type">
|
|
68
|
+
<span className="badge bg-accent/10 text-accent">{deployment.type}</span>
|
|
69
|
+
</InfoRow>
|
|
70
|
+
<InfoRow label="URL">
|
|
71
|
+
<a
|
|
72
|
+
href={appUrl(deployment.name)}
|
|
73
|
+
target="_blank"
|
|
74
|
+
rel="noopener noreferrer"
|
|
75
|
+
className="text-accent hover:text-accent-hover font-mono text-xs"
|
|
76
|
+
>
|
|
77
|
+
{deployment.name}.localhost
|
|
78
|
+
</a>
|
|
79
|
+
</InfoRow>
|
|
80
|
+
<InfoRow label="Created">{new Date(deployment.createdAt).toLocaleString()}</InfoRow>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{inspect?.env && inspect.env.length > 0 && (
|
|
85
|
+
<div className="card p-4">
|
|
86
|
+
<h3 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-3">
|
|
87
|
+
Environment Variables
|
|
88
|
+
</h3>
|
|
89
|
+
<div className="space-y-1">
|
|
90
|
+
{inspect.env.map((e, i) => {
|
|
91
|
+
const [key, ...rest] = e.split('=');
|
|
92
|
+
return (
|
|
93
|
+
<div key={i} className="flex gap-2 text-xs font-mono">
|
|
94
|
+
<span className="text-accent">{key}</span>
|
|
95
|
+
<span className="text-text-tertiary">=</span>
|
|
96
|
+
<span className="text-text-secondary">{rest.join('=')}</span>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
<div className="flex gap-2">
|
|
105
|
+
<a
|
|
106
|
+
href={appUrl(deployment.name)}
|
|
107
|
+
target="_blank"
|
|
108
|
+
rel="noopener noreferrer"
|
|
109
|
+
className="btn btn-primary btn-sm"
|
|
110
|
+
>
|
|
111
|
+
Open App
|
|
112
|
+
</a>
|
|
113
|
+
<button type="button" className="btn btn-sm" onClick={handleRestart}>
|
|
114
|
+
Restart
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import { useOutletContext } from 'react-router';
|
|
5
|
+
import { fetchRequestData as serverFetchRequests } from '../../../actions/deployments';
|
|
6
|
+
import { appUrl, getAuth, StatCard } from './shared';
|
|
7
|
+
import type { DetailContext } from './shared';
|
|
8
|
+
|
|
9
|
+
interface RequestLog {
|
|
10
|
+
method: string;
|
|
11
|
+
path: string;
|
|
12
|
+
status: number;
|
|
13
|
+
duration: number;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RequestSummary {
|
|
18
|
+
total: number;
|
|
19
|
+
statusCodes: Record<string, number>;
|
|
20
|
+
avgDuration: number;
|
|
21
|
+
recentRpm: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function Component() {
|
|
25
|
+
const { deployment } = useOutletContext<DetailContext>();
|
|
26
|
+
const name = deployment.name;
|
|
27
|
+
const [logs, setLogs] = useState<RequestLog[]>([]);
|
|
28
|
+
const [summary, setSummary] = useState<RequestSummary | null>(null);
|
|
29
|
+
const [loading, setLoading] = useState(true);
|
|
30
|
+
|
|
31
|
+
const fetchRequests = useCallback(async () => {
|
|
32
|
+
try {
|
|
33
|
+
const auth = getAuth();
|
|
34
|
+
if (!auth) return;
|
|
35
|
+
const data = await serverFetchRequests(auth.username, auth.token, name);
|
|
36
|
+
setLogs(data.logs as RequestLog[]);
|
|
37
|
+
setSummary(data.summary as RequestSummary);
|
|
38
|
+
} catch {
|
|
39
|
+
// may not have data yet
|
|
40
|
+
} finally {
|
|
41
|
+
setLoading(false);
|
|
42
|
+
}
|
|
43
|
+
}, [name]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
fetchRequests();
|
|
47
|
+
const interval = setInterval(fetchRequests, 3000);
|
|
48
|
+
return () => clearInterval(interval);
|
|
49
|
+
}, [fetchRequests]);
|
|
50
|
+
|
|
51
|
+
if (loading) {
|
|
52
|
+
return <div className="text-sm text-text-tertiary text-center py-8">Loading...</div>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="space-y-6">
|
|
57
|
+
<div className="card p-4">
|
|
58
|
+
<p className="text-xs text-text-tertiary mb-2">
|
|
59
|
+
App URL:{' '}
|
|
60
|
+
<a
|
|
61
|
+
href={appUrl(name)}
|
|
62
|
+
target="_blank"
|
|
63
|
+
rel="noopener noreferrer"
|
|
64
|
+
className="text-accent hover:text-accent-hover font-mono"
|
|
65
|
+
>
|
|
66
|
+
{name}.localhost
|
|
67
|
+
</a>
|
|
68
|
+
</p>
|
|
69
|
+
<p className="text-xs text-text-secondary">
|
|
70
|
+
All traffic to this subdomain is tracked automatically.
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{summary && summary.total > 0 ? (
|
|
75
|
+
<>
|
|
76
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
77
|
+
<StatCard label="Total Requests" value={String(summary.total)} />
|
|
78
|
+
<StatCard label="Avg Response" value={`${summary.avgDuration}ms`} />
|
|
79
|
+
<StatCard label="Requests/min" value={String(summary.recentRpm)} />
|
|
80
|
+
<div className="card p-4">
|
|
81
|
+
<p className="text-xs text-text-tertiary mb-1">Status Codes</p>
|
|
82
|
+
<div className="flex flex-wrap gap-2 mt-1">
|
|
83
|
+
{Object.entries(summary.statusCodes).map(([code, count]) => (
|
|
84
|
+
<span
|
|
85
|
+
key={code}
|
|
86
|
+
className={`text-xs font-mono px-1.5 py-0.5 rounded ${
|
|
87
|
+
code === '2xx'
|
|
88
|
+
? 'bg-success/10 text-success'
|
|
89
|
+
: code === '3xx'
|
|
90
|
+
? 'bg-accent/10 text-accent'
|
|
91
|
+
: code === '4xx'
|
|
92
|
+
? 'bg-warning/10 text-warning'
|
|
93
|
+
: 'bg-danger/10 text-danger'
|
|
94
|
+
}`}
|
|
95
|
+
>
|
|
96
|
+
{code}: {count}
|
|
97
|
+
</span>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div className="card overflow-hidden">
|
|
104
|
+
<h3 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider px-4 py-3 border-b border-border">
|
|
105
|
+
Recent Requests
|
|
106
|
+
</h3>
|
|
107
|
+
<div className="overflow-auto max-h-[400px]">
|
|
108
|
+
<table className="w-full text-sm">
|
|
109
|
+
<thead>
|
|
110
|
+
<tr className="border-b border-border text-left text-xs text-text-tertiary">
|
|
111
|
+
<th className="px-4 py-2 font-medium">Method</th>
|
|
112
|
+
<th className="px-4 py-2 font-medium">Path</th>
|
|
113
|
+
<th className="px-4 py-2 font-medium">Status</th>
|
|
114
|
+
<th className="px-4 py-2 font-medium">Duration</th>
|
|
115
|
+
<th className="px-4 py-2 font-medium">Time</th>
|
|
116
|
+
</tr>
|
|
117
|
+
</thead>
|
|
118
|
+
<tbody className="divide-y divide-border">
|
|
119
|
+
{[...logs]
|
|
120
|
+
.reverse()
|
|
121
|
+
.slice(0, 100)
|
|
122
|
+
.map((log, i) => (
|
|
123
|
+
<tr key={i} className="hover:bg-bg-hover transition-colors">
|
|
124
|
+
<td className="px-4 py-2 font-mono text-xs font-medium">{log.method}</td>
|
|
125
|
+
<td className="px-4 py-2 font-mono text-xs text-text-secondary truncate max-w-[200px]">
|
|
126
|
+
{log.path}
|
|
127
|
+
</td>
|
|
128
|
+
<td className="px-4 py-2">
|
|
129
|
+
<span
|
|
130
|
+
className={`text-xs font-mono px-1.5 py-0.5 rounded ${
|
|
131
|
+
log.status < 300
|
|
132
|
+
? 'bg-success/10 text-success'
|
|
133
|
+
: log.status < 400
|
|
134
|
+
? 'bg-accent/10 text-accent'
|
|
135
|
+
: log.status < 500
|
|
136
|
+
? 'bg-warning/10 text-warning'
|
|
137
|
+
: 'bg-danger/10 text-danger'
|
|
138
|
+
}`}
|
|
139
|
+
>
|
|
140
|
+
{log.status}
|
|
141
|
+
</span>
|
|
142
|
+
</td>
|
|
143
|
+
<td className="px-4 py-2 font-mono text-xs text-text-secondary">
|
|
144
|
+
{log.duration}ms
|
|
145
|
+
</td>
|
|
146
|
+
<td className="px-4 py-2 text-xs text-text-tertiary">
|
|
147
|
+
{new Date(log.timestamp).toLocaleTimeString()}
|
|
148
|
+
</td>
|
|
149
|
+
</tr>
|
|
150
|
+
))}
|
|
151
|
+
</tbody>
|
|
152
|
+
</table>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</>
|
|
156
|
+
) : (
|
|
157
|
+
<div className="card p-6 text-center text-sm text-text-secondary">
|
|
158
|
+
No requests recorded yet. Send traffic to the app URL above to see analytics.
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|