Skip to content

Commit 3ff38aa

Browse files
u
1 parent f3bb56b commit 3ff38aa

42 files changed

Lines changed: 360 additions & 245 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ jobs:
4444
run: pnpm lint
4545

4646
- name: Test
47-
run: pnpm test --passWithNoTests
47+
run: pnpm test
4848

4949
- name: Restore Next.js build cache
5050
uses: actions/cache@v6

‎docs/configuration.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ On Coolify, `DOMAIN` falls back to the domain assigned to the `app` service. See
2727

2828
- **Secure cookies.** The session cookie's `Secure` flag follows the public URL from `DOMAIN` / `APP_URL`. The URL is `https://` unless the domain is `localhost` or `127.0.0.1`. Serve the instance over HTTPS so browsers send the cookie.
2929
- **Client IP.** Rate limits, the audit log and the device list use the client IP, which comes from the proxy's headers:
30-
1. `X-Real-IP`, if the proxy sets it
31-
2. otherwise the **right-most** `X-Forwarded-For` entry, which is the hop appended by your proxy. Left-most entries are client-controlled and ignored.
30+
1. the **right-most** `X-Forwarded-For` entry, which is the hop appended by your proxy. Left-most entries are client-controlled and ignored.
31+
2. `X-Real-IP`, only when there is no `X-Forwarded-For` header at all.
3232

33-
This assumes a **single trusted reverse proxy** directly in front of Dispatch (Traefik on Coolify, the bundled Caddy, or your nginx). With a second proxy or CDN in front, e.g. Cloudflare → Traefik, configure the inner proxy to set `X-Real-IP` to the real client address. Otherwise all requests appear to come from the CDN. Never publish port 3000 to the internet without a proxy, because clients could then send these headers themselves.
33+
This assumes a **single trusted reverse proxy** directly in front of Dispatch (Traefik on Coolify, the bundled Caddy, or your nginx); all of them append the connecting address to `X-Forwarded-For` by default. With a CDN in front (e.g. Cloudflare → Traefik), the right-most hop is the CDN's edge address, so per-IP rate limits apply per edge location; configure your inner proxy to *replace* `X-Forwarded-For` with the real client address (e.g. Cloudflare's `CF-Connecting-IP`) if you need exact client IPs. Never publish port 3000 to the internet without a proxy, because clients could then send these headers themselves.
3434

3535
### `DISPATCH_VERSION` (compose only, optional)
3636

‎docs/social-preview.png‎

67.1 KB
Loading

‎src/app/admin/audit/page.tsx‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { requireSuperAdminPage } from "@/server/authz"
44
import { getAuditFilterOptions, isIsoDate, isUuid, listAuditEvents, type AuditEvent } from "@/server/admin/audit"
55
import { AdminPageHeader, EmptyState, formatNumber } from "@/components/admin/ui"
66
import { Pager } from "@/components/admin/client"
7-
import { formatDateTime, timeAgo } from "@/components/admin/format"
87
import { AuditFilters } from "@/components/admin/audit/audit-filters"
98
import { AuditTable, type AuditRowView } from "@/components/admin/audit/audit-table"
109

@@ -69,8 +68,6 @@ export default async function AdminAuditPage({ searchParams }: PageProps<"/admin
6968
const rows: AuditRowView[] = events.rows.map((e) => ({
7069
id: e.id,
7170
iso: e.createdAt.toISOString(),
72-
time: formatDateTime(e.createdAt),
73-
ago: timeAgo(e.createdAt),
7471
orgId: e.orgId,
7572
orgName: e.orgName,
7673
actorId: e.actorId,

‎src/app/admin/system/page.tsx‎

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
Th,
2424
formatNumber,
2525
} from "@/components/admin/ui"
26-
import { formatDateTime, isoOrUndefined, timeAgo } from "@/components/admin/format"
26+
import { LocalTime } from "@/components/app/local-time"
2727
import { FailedJobsList, RecheckButton, RetryAllButton, type FailedJobView } from "@/components/admin/system/system-client"
2828
import { cn } from "@/lib/utils"
2929

@@ -47,7 +47,7 @@ const PROVIDER_LABEL: Record<string, string> = {
4747
demo: "Demo",
4848
}
4949

50-
function JobCounter({ label, value, hint, tone }: { label: string; value: number; hint?: string; tone?: "warn" | "error" }) {
50+
function JobCounter({ label, value, hint, tone }: { label: string; value: number; hint?: React.ReactNode; tone?: "warn" | "error" }) {
5151
return (
5252
<div className="rounded-md border border-border bg-surface/50 px-3 py-2.5">
5353
<MicroLabel>{label}</MicroLabel>
@@ -84,9 +84,8 @@ export default async function AdminSystemPage() {
8484
maxAttempts: j.maxAttempts,
8585
lastError: j.lastError,
8686
payloadJson: JSON.stringify(j.payload, null, 2),
87-
created: formatDateTime(j.createdAt),
88-
createdAgo: timeAgo(j.createdAt),
89-
runAgo: timeAgo(j.runAt),
87+
createdAt: j.createdAt.toISOString(),
88+
runAt: j.runAt.toISOString(),
9089
}))
9190

9291
return (
@@ -142,21 +141,25 @@ export default async function AdminSystemPage() {
142141
{
143142
label: "Last heartbeat",
144143
value: worker.heartbeatAt ? (
145-
<time dateTime={isoOrUndefined(worker.heartbeatAt)} title={formatDateTime(worker.heartbeatAt)}>
146-
{timeAgo(worker.heartbeatAt)}
147-
</time>
144+
<LocalTime date={worker.heartbeatAt} format="relative" titleFormat="datetime" />
148145
) : (
149146
"—"
150147
),
151148
},
152149
{
153150
label: "Uptime",
154151
value:
155-
worker.online && worker.startedAt && worker.uptimeSeconds !== null
156-
? `${formatDuration(worker.uptimeSeconds)} · since ${formatDateTime(worker.startedAt)}`
157-
: worker.startedAt
158-
? `Last started ${formatDateTime(worker.startedAt)}`
159-
: "—",
152+
worker.online && worker.startedAt && worker.uptimeSeconds !== null ? (
153+
<>
154+
{formatDuration(worker.uptimeSeconds)} · since <LocalTime date={worker.startedAt} />
155+
</>
156+
) : worker.startedAt ? (
157+
<>
158+
Last started <LocalTime date={worker.startedAt} />
159+
</>
160+
) : (
161+
"—"
162+
),
160163
},
161164
{
162165
label: "Version",
@@ -223,7 +226,11 @@ export default async function AdminSystemPage() {
223226
jobs.retrying
224227
? `${formatNumber(jobs.retrying)} retrying`
225228
: jobs.oldestPendingAt
226-
? `oldest ${timeAgo(jobs.oldestPendingAt)}`
229+
? (
230+
<>
231+
oldest <LocalTime date={jobs.oldestPendingAt} format="relative" />
232+
</>
233+
)
227234
: "Queue is clear"
228235
}
229236
/>
@@ -303,7 +310,9 @@ export default async function AdminSystemPage() {
303310
<Link href={`/admin/workspaces/${a.orgId}`} className="underline underline-offset-2">
304311
{a.orgName}
305312
</Link>
306-
<span>Last synced {a.lastSyncedAt ? timeAgo(a.lastSyncedAt) : "never"}</span>
313+
<span>
314+
Last synced <LocalTime date={a.lastSyncedAt} format="relative" fallback="never" />
315+
</span>
307316
</div>
308317
</li>
309318
))}
@@ -343,9 +352,7 @@ export default async function AdminSystemPage() {
343352
</Td>
344353
<Td className="text-[13px] whitespace-nowrap text-muted-foreground">
345354
{a.lastSyncedAt ? (
346-
<time dateTime={isoOrUndefined(a.lastSyncedAt)} title={formatDateTime(a.lastSyncedAt)}>
347-
{timeAgo(a.lastSyncedAt)}
348-
</time>
355+
<LocalTime date={a.lastSyncedAt} format="relative" titleFormat="datetime" />
349356
) : (
350357
"Never"
351358
)}

‎src/app/admin/users/[id]/page.tsx‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { getUserDetail } from "@/server/admin/users"
88
import { UserAvatar } from "@/components/app/user-avatar"
99
import { AdminPageHeader, EmptyState, KeyValueList, MicroLabel, Panel, StatusBadge } from "@/components/admin/ui"
1010
import { PostButton } from "@/components/admin/client"
11-
import { formatDateTime, timeAgo } from "@/components/admin/format"
11+
import { LocalTime } from "@/components/app/local-time"
1212
import {
1313
AccountStatusControl,
1414
DeleteUserControl,
@@ -65,8 +65,8 @@ export default async function AdminUserDetailPage({ params, searchParams }: Page
6565
id: s.id,
6666
deviceLabel: s.deviceLabel || "Unknown device",
6767
ip: s.ip,
68-
created: timeAgo(s.createdAt),
69-
lastUsed: timeAgo(s.lastUsedAt),
68+
createdAt: s.createdAt.toISOString(),
69+
lastUsedAt: s.lastUsedAt.toISOString(),
7070
impersonation: Boolean(s.impersonatorId),
7171
current: s.id === mySession.id,
7272
mobile: s.userAgent ? ["mobile", "tablet"].includes(new UAParser(s.userAgent).getResult().device.type ?? "") : false,
@@ -90,7 +90,8 @@ export default async function AdminUserDetailPage({ params, searchParams }: Page
9090
)}
9191
<StatusBadge tone={user.status === "active" ? "ok" : "error"}>{user.status === "active" ? "Active" : "Disabled"}</StatusBadge>
9292
<span className="text-muted-foreground">
93-
· joined {timeAgo(user.createdAt)} · last seen {timeAgo(user.lastSeenAt, "never")}
93+
· joined <LocalTime date={user.createdAt} format="relative" titleFormat="datetime" /> · last seen{" "}
94+
<LocalTime date={user.lastSeenAt} format="relative" titleFormat="datetime" fallback="never" />
9495
</span>
9596
</span>
9697
}
@@ -121,9 +122,18 @@ export default async function AdminUserDetailPage({ params, searchParams }: Page
121122
{ label: "Email", value: user.email },
122123
{ label: "Name", value: user.name || <span className="text-muted-foreground">Not set</span> },
123124
{ label: "Time zone", value: user.timezone || <span className="text-muted-foreground">Instance default</span> },
124-
{ label: "Created", value: formatDateTime(user.createdAt) },
125-
{ label: "Last seen", value: user.lastSeenAt ? `${formatDateTime(user.lastSeenAt)} (${timeAgo(user.lastSeenAt)})` : "Never" },
126-
...(user.awayUntil && user.awayUntil > new Date() ? [{ label: "Away until", value: formatDateTime(user.awayUntil) }] : []),
125+
{ label: "Created", value: <LocalTime date={user.createdAt} /> },
126+
{
127+
label: "Last seen",
128+
value: user.lastSeenAt ? (
129+
<>
130+
<LocalTime date={user.lastSeenAt} /> (<LocalTime date={user.lastSeenAt} format="relative" />)
131+
</>
132+
) : (
133+
"Never"
134+
),
135+
},
136+
...(user.awayUntil && user.awayUntil > new Date() ? [{ label: "Away until", value: <LocalTime date={user.awayUntil} /> }] : []),
127137
]}
128138
/>
129139
</Panel>
@@ -148,7 +158,7 @@ export default async function AdminUserDetailPage({ params, searchParams }: Page
148158
<div className="min-w-0 flex-1">
149159
<div className="truncate text-sm font-medium">{m.orgName}</div>
150160
<div className="truncate font-mono text-[12px] text-muted-foreground">
151-
/{m.orgSlug} · joined {timeAgo(m.joinedAt)}
161+
/{m.orgSlug} · joined <LocalTime date={m.joinedAt} format="relative" titleFormat="datetime" />
152162
</div>
153163
</div>
154164
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5">
@@ -196,9 +206,7 @@ export default async function AdminUserDetailPage({ params, searchParams }: Page
196206
{e.targetType && ` · ${e.targetType}`}
197207
{e.ip && ` · ${e.ip}`}
198208
</span>
199-
<time dateTime={e.createdAt.toISOString()} title={formatDateTime(e.createdAt)} className="shrink-0 text-[12px] text-muted-foreground">
200-
{timeAgo(e.createdAt)}
201-
</time>
209+
<LocalTime date={e.createdAt} format="relative" titleFormat="datetime" className="shrink-0 text-[12px] text-muted-foreground" />
202210
</li>
203211
))}
204212
</ul>

‎src/app/admin/users/page.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { listUsers } from "@/server/admin/users"
66
import { UserAvatar } from "@/components/app/user-avatar"
77
import { AdminPageHeader, DataTable, EmptyState, StatusBadge, Td, Th, formatNumber } from "@/components/admin/ui"
88
import { FilterSelect, Pager, SearchParamInput } from "@/components/admin/client"
9-
import { formatDate, timeAgo } from "@/components/admin/format"
9+
import { LocalTime } from "@/components/app/local-time"
1010

1111
export const metadata: Metadata = { title: "Users" }
1212

@@ -73,7 +73,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps<"/admin
7373
)}
7474
{u.status === "disabled" && <StatusBadge tone="error">Disabled</StatusBadge>}
7575
<span className="text-[12px] text-muted-foreground">
76-
{u.workspaces} {u.workspaces === 1 ? "workspace" : "workspaces"} · seen {timeAgo(u.lastSeenAt, "never")}
76+
{u.workspaces} {u.workspaces === 1 ? "workspace" : "workspaces"} · seen <LocalTime date={u.lastSeenAt} format="relative" fallback="never" />
7777
</span>
7878
</div>
7979
</div>
@@ -126,9 +126,13 @@ export default async function AdminUsersPage({ searchParams }: PageProps<"/admin
126126
<Td>
127127
<StatusBadge tone={u.status === "active" ? "ok" : "error"}>{u.status === "active" ? "Active" : "Disabled"}</StatusBadge>
128128
</Td>
129-
<Td className="text-[13px] whitespace-nowrap text-muted-foreground">{timeAgo(u.lastSeenAt, "Never")}</Td>
129+
<Td className="text-[13px] whitespace-nowrap text-muted-foreground">
130+
<LocalTime date={u.lastSeenAt} format="relative" titleFormat="datetime" fallback="Never" />
131+
</Td>
130132
<Td className="text-right tabular-nums">{formatNumber(u.sessions)}</Td>
131-
<Td className="text-[13px] whitespace-nowrap text-muted-foreground">{formatDate(u.createdAt)}</Td>
133+
<Td className="text-[13px] whitespace-nowrap text-muted-foreground">
134+
<LocalTime date={u.createdAt} format="date" />
135+
</Td>
132136
</tr>
133137
))}
134138
</tbody>

‎src/app/admin/workspaces/[id]/page.tsx‎

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
formatNumber,
1818
} from "@/components/admin/ui"
1919
import { PostButton } from "@/components/admin/client"
20-
import { formatDate, formatDateTime, timeAgo } from "@/components/admin/format"
20+
import { LocalTime } from "@/components/app/local-time"
2121
import {
2222
PROVIDER_LABELS,
2323
accountStatusTone,
@@ -91,7 +91,9 @@ export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/a
9191
) : (
9292
<StatusBadge tone={subscriptionTone(org.subscriptionStatus)}>{subscriptionLabel(org.subscriptionStatus)}</StatusBadge>
9393
)}
94-
<span className="text-muted-foreground">· created {timeAgo(org.createdAt)}</span>
94+
<span className="text-muted-foreground">
95+
· created <LocalTime date={org.createdAt} format="relative" titleFormat="datetime" />
96+
</span>
9597
</span>
9698
}
9799
actions={
@@ -109,7 +111,9 @@ export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/a
109111
>
110112
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
111113
<div className="min-w-0">
112-
<div className="font-medium">Suspended {timeAgo(org.suspendedAt)} — the workspace is read-only.</div>
114+
<div className="font-medium">
115+
Suspended <LocalTime date={org.suspendedAt} format="relative" titleFormat="datetime" /> — the workspace is read-only.
116+
</div>
113117
<div className="mt-0.5 break-words opacity-90">{org.suspendedReason || "No reason given."}</div>
114118
</div>
115119
</div>
@@ -154,7 +158,7 @@ export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/a
154158
label: "Created",
155159
value: (
156160
<>
157-
{formatDateTime(org.createdAt)}
161+
<LocalTime date={org.createdAt} />
158162
{creator && (
159163
<>
160164
{" "}
@@ -167,8 +171,20 @@ export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/a
167171
</>
168172
),
169173
},
170-
{ label: "Onboarding", value: org.onboardingCompletedAt ? `Completed ${formatDate(org.onboardingCompletedAt)}` : "Not completed" },
171-
{ label: "Last activity", value: timeAgo(stats.lastActivityAt, "No activity yet") },
174+
{
175+
label: "Onboarding",
176+
value: org.onboardingCompletedAt ? (
177+
<>
178+
Completed <LocalTime date={org.onboardingCompletedAt} format="date" />
179+
</>
180+
) : (
181+
"Not completed"
182+
),
183+
},
184+
{
185+
label: "Last activity",
186+
value: <LocalTime date={stats.lastActivityAt} format="relative" titleFormat="datetime" fallback="No activity yet" />,
187+
},
172188
{ label: "Teams", value: formatNumber(stats.teams) },
173189
{ label: "Time zone", value: org.settings?.timezone || "Instance default" },
174190
]}
@@ -199,7 +215,7 @@ export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/a
199215
<div className="truncate text-[12.5px] text-muted-foreground">
200216
{m.name ? m.email : null}
201217
{m.name ? " · " : null}
202-
seen {timeAgo(m.lastSeenAt, "never")}
218+
seen <LocalTime date={m.lastSeenAt} format="relative" titleFormat="datetime" fallback="never" />
203219
</div>
204220
</div>
205221
<div className="flex shrink-0 flex-col items-end gap-1 sm:flex-row sm:items-center sm:gap-2">
@@ -243,7 +259,9 @@ export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/a
243259
<StatusBadge tone={accountStatusTone(a.status)} pulse={a.status === "syncing"}>
244260
{a.status[0]!.toUpperCase() + a.status.slice(1)}
245261
</StatusBadge>
246-
<span className="text-[11.5px] text-muted-foreground">synced {timeAgo(a.lastSyncedAt, "never")}</span>
262+
<span className="text-[11.5px] text-muted-foreground">
263+
synced <LocalTime date={a.lastSyncedAt} format="relative" titleFormat="datetime" fallback="never" />
264+
</span>
247265
</div>
248266
</div>
249267
{a.lastError && (
@@ -264,12 +282,12 @@ export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/a
264282
id={org.id}
265283
plan={org.plan}
266284
status={org.subscriptionStatus}
267-
trialEndsLabel={org.trialEndsAt ? formatDate(org.trialEndsAt) : null}
285+
trialEndsAt={org.trialEndsAt?.toISOString() ?? null}
268286
trialExpired={trialExpired}
269287
stripeCustomerId={org.stripeCustomerId}
270288
stripeCustomerUrl={stripeCustomerUrl}
271289
stripeSubscriptionId={org.stripeSubscriptionId}
272-
currentPeriodLabel={org.currentPeriodEndsAt ? formatDate(org.currentPeriodEndsAt) : null}
290+
currentPeriodEndsAt={org.currentPeriodEndsAt?.toISOString() ?? null}
273291
/>
274292
</Panel>
275293

0 commit comments

Comments
 (0)