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.
Files changed (135) hide show
  1. package/.claude/settings.local.json +36 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
  3. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.yml +56 -0
  5. package/.github/workflows/ci.yml +15 -34
  6. package/.github/workflows/pages.yml +48 -0
  7. package/.oxfmtrc.json +7 -0
  8. package/.oxlintrc.json +11 -0
  9. package/LICENSE +183 -183
  10. package/README.md +99 -10
  11. package/app/actions/deployments.ts +82 -0
  12. package/app/actions/metrics.ts +13 -0
  13. package/app/root.tsx +60 -0
  14. package/app/routes/dashboard/detail/history.tsx +73 -0
  15. package/app/routes/dashboard/detail/layout.tsx +125 -0
  16. package/app/routes/dashboard/detail/logs.tsx +85 -0
  17. package/app/routes/dashboard/detail/overview.tsx +119 -0
  18. package/app/routes/dashboard/detail/requests.tsx +163 -0
  19. package/app/routes/dashboard/detail/resources.tsx +268 -0
  20. package/app/routes/dashboard/detail/shared.tsx +59 -0
  21. package/app/routes/dashboard/index.tsx +360 -0
  22. package/app/routes/dashboard/layout.tsx +30 -0
  23. package/app/routes/docs/architecture.tsx +155 -0
  24. package/app/routes/docs/cli.tsx +122 -0
  25. package/app/routes/docs/deploying.tsx +105 -0
  26. package/app/routes/docs/index.tsx +104 -0
  27. package/app/routes/docs/layout.tsx +58 -0
  28. package/app/routes/home.tsx +134 -0
  29. package/app/routes/root.client.tsx +46 -0
  30. package/app/routes.ts +21 -0
  31. package/app/styles.css +15 -0
  32. package/app/theme.css +134 -0
  33. package/bin/deploy.js +362 -138
  34. package/docs-site/404.html +33 -0
  35. package/docs-site/home.tsx +130 -0
  36. package/docs-site/index.html +35 -0
  37. package/docs-site/layout.tsx +57 -0
  38. package/docs-site/main.tsx +41 -0
  39. package/docs-site/shell.tsx +34 -0
  40. package/docs-site/styles.css +4 -0
  41. package/drizzle.config.js +8 -0
  42. package/examples/docker/Dockerfile +5 -5
  43. package/examples/docker/server.js +18 -0
  44. package/examples/node/package.json +3 -11
  45. package/examples/node/pnpm-lock.yaml +9 -0
  46. package/examples/node/server.js +12 -0
  47. package/examples/static/index.html +41 -15
  48. package/package.json +40 -64
  49. package/public/favicon.ico +0 -0
  50. package/react-router-vite/entry.browser.tsx +49 -0
  51. package/react-router-vite/entry.rsc.single.tsx +7 -0
  52. package/react-router-vite/entry.rsc.tsx +36 -0
  53. package/react-router-vite/entry.ssr.tsx +29 -0
  54. package/react-router-vite/plugin.ts +114 -0
  55. package/react-router-vite/types.d.ts +11 -0
  56. package/react-router.config.ts +5 -0
  57. package/server/api.test.ts +344 -0
  58. package/server/api.ts +445 -0
  59. package/server/docker.ts +268 -0
  60. package/server/index.ts +17 -0
  61. package/server/metrics-collector.ts +29 -0
  62. package/server/schema.ts +56 -0
  63. package/server/store.test.ts +278 -0
  64. package/server/store.ts +398 -0
  65. package/tsconfig.json +21 -0
  66. package/vite.config.ts +45 -0
  67. package/vite.docs.config.ts +31 -0
  68. package/.eslintignore +0 -6
  69. package/.eslintrc +0 -12
  70. package/.husky/pre-commit +0 -5
  71. package/.prettierrc +0 -0
  72. package/.release-it.json +0 -5
  73. package/CHANGELOG.md +0 -56
  74. package/__tests__/fixtures/unknown/.gitkeep +0 -0
  75. package/__tests__/lib/classifier.test.js +0 -49
  76. package/__tests__/lib/helpers/util.test.js +0 -57
  77. package/bin/deploy-delete.js +0 -14
  78. package/bin/deploy-deploy.js +0 -36
  79. package/bin/deploy-list.js +0 -40
  80. package/bin/deploy-login.js +0 -43
  81. package/bin/deploy-logout.js +0 -16
  82. package/bin/deploy-logs.js +0 -26
  83. package/bin/deploy-open.js +0 -26
  84. package/bin/deploy-register.js +0 -45
  85. package/bin/deploy-server.js +0 -11
  86. package/bin/deploy-whoami.js +0 -14
  87. package/examples/docker/index.js +0 -12
  88. package/examples/node/index.js +0 -8
  89. package/examples/static/main.css +0 -9
  90. package/examples/static/out.gifcd +0 -0
  91. package/generate-docs.js +0 -55
  92. package/index.js +0 -69
  93. package/jsdoc.json +0 -27
  94. package/lib/classifier.js +0 -63
  95. package/lib/deploy.js +0 -70
  96. package/lib/helpers/cli.js +0 -262
  97. package/lib/helpers/util.js +0 -140
  98. package/lib/models/deployment.js +0 -474
  99. package/lib/models/request.js +0 -101
  100. package/lib/models/user.js +0 -147
  101. package/lib/server.js +0 -211
  102. package/lib/static/not-found.html +0 -30
  103. package/lib/static/page-could-not-load.html +0 -30
  104. package/lib/static/static-server.js +0 -70
  105. package/website/README.md +0 -41
  106. package/website/babel.config.js +0 -3
  107. package/website/docs/api/_category_.yml +0 -1
  108. package/website/docs/api/lib/classifier.js.md +0 -11
  109. package/website/docs/api/lib/deploy.js.md +0 -13
  110. package/website/docs/api/lib/helpers/cli.js.md +0 -193
  111. package/website/docs/api/lib/helpers/util.js.md +0 -65
  112. package/website/docs/api/lib/models/deployment.js.md +0 -171
  113. package/website/docs/api/lib/models/request.js.md +0 -67
  114. package/website/docs/api/lib/models/user.js.md +0 -92
  115. package/website/docs/api/lib/server.js.md +0 -0
  116. package/website/docs/api/lib/static/static-server.js.md +0 -0
  117. package/website/docs/intro.md +0 -57
  118. package/website/docusaurus.config.js +0 -82
  119. package/website/package-lock.json +0 -25218
  120. package/website/package.json +0 -39
  121. package/website/sidebars.js +0 -31
  122. package/website/src/components/HomepageFeatures/index.js +0 -79
  123. package/website/src/components/HomepageFeatures/styles.module.css +0 -11
  124. package/website/src/css/custom.css +0 -39
  125. package/website/src/pages/index.js +0 -57
  126. package/website/src/pages/index.module.css +0 -23
  127. package/website/static/.nojekyll +0 -0
  128. package/website/static/example.gif +0 -0
  129. package/website/static/example.mov +0 -0
  130. package/website/static/img/favicon.ico +0 -0
  131. package/website/static/img/intro/deploy.png +0 -0
  132. package/website/static/img/intro/logs.png +0 -0
  133. package/website/static/img/logo.png +0 -0
  134. package/website/static/img/logo.pxm +0 -0
  135. 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
+ &larr; 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
+ &larr;
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
+ }