feat: add Database admin page with pool, tables, queries, and thresholds UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
317
ui/src/pages/admin/DatabaseAdminPage.module.css
Normal file
317
ui/src/pages/admin/DatabaseAdminPage.module.css
Normal file
@@ -0,0 +1,317 @@
|
||||
.page {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.headerInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.headerMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.globalRefresh {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.globalRefresh:hover {
|
||||
border-color: var(--amber-dim);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.accessDenied {
|
||||
text-align: center;
|
||||
padding: 64px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ─── Progress Bar ─── */
|
||||
.progressContainer {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progressLabel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.progressPct {
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 8px;
|
||||
background: var(--bg-raised);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* ─── Metrics Grid ─── */
|
||||
.metricsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.metricLabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ─── Tables ─── */
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 8px 12px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.queryCell {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rowWarning {
|
||||
background: rgba(234, 179, 8, 0.06);
|
||||
}
|
||||
|
||||
.killBtn {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
border: 1px solid var(--rose-dim);
|
||||
color: var(--rose);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.killBtn:hover {
|
||||
background: var(--rose-glow);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ─── Maintenance ─── */
|
||||
.maintenanceGrid {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.maintenanceBtn {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ─── Thresholds ─── */
|
||||
.thresholdGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.thresholdField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.thresholdLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.thresholdInput {
|
||||
width: 100%;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.thresholdInput:focus {
|
||||
border-color: var(--amber-dim);
|
||||
box-shadow: 0 0 0 3px var(--amber-glow);
|
||||
}
|
||||
|
||||
.thresholdActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
padding: 8px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--amber);
|
||||
background: var(--amber);
|
||||
color: #0a0e17;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btnPrimary:hover {
|
||||
background: var(--amber-hover);
|
||||
border-color: var(--amber-hover);
|
||||
}
|
||||
|
||||
.btnPrimary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.successMsg {
|
||||
font-size: 12px;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.errorMsg {
|
||||
font-size: 12px;
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.metricsGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.thresholdGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
379
ui/src/pages/admin/DatabaseAdminPage.tsx
Normal file
379
ui/src/pages/admin/DatabaseAdminPage.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import { StatusBadge } from '../../components/admin/StatusBadge';
|
||||
import { RefreshableCard } from '../../components/admin/RefreshableCard';
|
||||
import { ConfirmDeleteDialog } from '../../components/admin/ConfirmDeleteDialog';
|
||||
import {
|
||||
useDatabaseStatus,
|
||||
useDatabasePool,
|
||||
useDatabaseTables,
|
||||
useDatabaseQueries,
|
||||
useKillQuery,
|
||||
} from '../../api/queries/admin/database';
|
||||
import { useThresholds, useSaveThresholds, type Thresholds } from '../../api/queries/admin/thresholds';
|
||||
import styles from './DatabaseAdminPage.module.css';
|
||||
|
||||
export function DatabaseAdminPage() {
|
||||
const roles = useAuthStore((s) => s.roles);
|
||||
|
||||
if (!roles.includes('ADMIN')) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.accessDenied}>
|
||||
Access Denied — this page requires the ADMIN role.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DatabaseAdminContent />;
|
||||
}
|
||||
|
||||
function DatabaseAdminContent() {
|
||||
const status = useDatabaseStatus();
|
||||
const pool = useDatabasePool();
|
||||
const tables = useDatabaseTables();
|
||||
const queries = useDatabaseQueries();
|
||||
const thresholds = useThresholds();
|
||||
|
||||
if (status.isLoading) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={styles.pageTitle}>Database Administration</h1>
|
||||
<div className={styles.loading}>Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const db = status.data;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerInfo}>
|
||||
<h1 className={styles.pageTitle}>Database Administration</h1>
|
||||
<div className={styles.headerMeta}>
|
||||
<StatusBadge
|
||||
status={db?.connected ? 'healthy' : 'critical'}
|
||||
label={db?.connected ? 'Connected' : 'Disconnected'}
|
||||
/>
|
||||
{db?.version && <span className={styles.metaItem}>{db.version}</span>}
|
||||
{db?.host && <span className={styles.metaItem}>{db.host}</span>}
|
||||
{db?.schema && <span className={styles.metaItem}>Schema: {db.schema}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.globalRefresh}
|
||||
onClick={() => {
|
||||
status.refetch();
|
||||
pool.refetch();
|
||||
tables.refetch();
|
||||
queries.refetch();
|
||||
}}
|
||||
>
|
||||
Refresh All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PoolSection
|
||||
pool={pool}
|
||||
warningPct={thresholds.data?.poolWarningPercent}
|
||||
criticalPct={thresholds.data?.poolCriticalPercent}
|
||||
/>
|
||||
<TablesSection tables={tables} />
|
||||
<QueriesSection
|
||||
queries={queries}
|
||||
warningSeconds={thresholds.data?.queryDurationWarningSeconds}
|
||||
/>
|
||||
<MaintenanceSection />
|
||||
<ThresholdsSection thresholds={thresholds.data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PoolSection({
|
||||
pool,
|
||||
warningPct,
|
||||
criticalPct,
|
||||
}: {
|
||||
pool: ReturnType<typeof useDatabasePool>;
|
||||
warningPct?: number;
|
||||
criticalPct?: number;
|
||||
}) {
|
||||
const data = pool.data;
|
||||
if (!data) return null;
|
||||
|
||||
const usagePct = data.maxConnections > 0
|
||||
? Math.round((data.activeConnections / data.maxConnections) * 100)
|
||||
: 0;
|
||||
const barColor =
|
||||
criticalPct && usagePct >= criticalPct ? '#ef4444'
|
||||
: warningPct && usagePct >= warningPct ? '#eab308'
|
||||
: '#22c55e';
|
||||
|
||||
return (
|
||||
<RefreshableCard
|
||||
title="Connection Pool"
|
||||
onRefresh={() => pool.refetch()}
|
||||
isRefreshing={pool.isFetching}
|
||||
autoRefresh
|
||||
>
|
||||
<div className={styles.progressContainer}>
|
||||
<div className={styles.progressLabel}>
|
||||
{data.activeConnections} / {data.maxConnections} connections
|
||||
<span className={styles.progressPct}>{usagePct}%</span>
|
||||
</div>
|
||||
<div className={styles.progressBar}>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{ width: `${usagePct}%`, background: barColor }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.metricsGrid}>
|
||||
<div className={styles.metric}>
|
||||
<span className={styles.metricValue}>{data.activeConnections}</span>
|
||||
<span className={styles.metricLabel}>Active</span>
|
||||
</div>
|
||||
<div className={styles.metric}>
|
||||
<span className={styles.metricValue}>{data.idleConnections}</span>
|
||||
<span className={styles.metricLabel}>Idle</span>
|
||||
</div>
|
||||
<div className={styles.metric}>
|
||||
<span className={styles.metricValue}>{data.pendingConnections}</span>
|
||||
<span className={styles.metricLabel}>Pending</span>
|
||||
</div>
|
||||
<div className={styles.metric}>
|
||||
<span className={styles.metricValue}>{data.maxWaitMillis}ms</span>
|
||||
<span className={styles.metricLabel}>Max Wait</span>
|
||||
</div>
|
||||
</div>
|
||||
</RefreshableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function TablesSection({ tables }: { tables: ReturnType<typeof useDatabaseTables> }) {
|
||||
const data = tables.data;
|
||||
|
||||
return (
|
||||
<RefreshableCard
|
||||
title="Table Sizes"
|
||||
onRefresh={() => tables.refetch()}
|
||||
isRefreshing={tables.isFetching}
|
||||
>
|
||||
{!data ? (
|
||||
<div className={styles.loading}>Loading...</div>
|
||||
) : (
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Table</th>
|
||||
<th>Rows</th>
|
||||
<th>Data Size</th>
|
||||
<th>Index Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((t) => (
|
||||
<tr key={t.tableName}>
|
||||
<td className={styles.mono}>{t.tableName}</td>
|
||||
<td>{t.rowEstimate.toLocaleString()}</td>
|
||||
<td>{t.dataSize}</td>
|
||||
<td>{t.indexSize}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</RefreshableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function QueriesSection({
|
||||
queries,
|
||||
warningSeconds,
|
||||
}: {
|
||||
queries: ReturnType<typeof useDatabaseQueries>;
|
||||
warningSeconds?: number;
|
||||
}) {
|
||||
const [killTarget, setKillTarget] = useState<number | null>(null);
|
||||
const killMutation = useKillQuery();
|
||||
const data = queries.data;
|
||||
|
||||
const warningMs = (warningSeconds ?? 30) * 1000;
|
||||
|
||||
return (
|
||||
<RefreshableCard
|
||||
title="Active Queries"
|
||||
onRefresh={() => queries.refetch()}
|
||||
isRefreshing={queries.isFetching}
|
||||
autoRefresh
|
||||
>
|
||||
{!data || data.length === 0 ? (
|
||||
<div className={styles.emptyState}>No active queries</div>
|
||||
) : (
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PID</th>
|
||||
<th>Duration</th>
|
||||
<th>State</th>
|
||||
<th>Query</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((q) => (
|
||||
<tr
|
||||
key={q.pid}
|
||||
className={q.durationMs > warningMs ? styles.rowWarning : undefined}
|
||||
>
|
||||
<td className={styles.mono}>{q.pid}</td>
|
||||
<td>{formatDuration(q.durationMs)}</td>
|
||||
<td>{q.state}</td>
|
||||
<td className={styles.queryCell} title={q.query}>
|
||||
{q.query.length > 100 ? `${q.query.slice(0, 100)}...` : q.query}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.killBtn}
|
||||
onClick={() => setKillTarget(q.pid)}
|
||||
>
|
||||
Kill
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDeleteDialog
|
||||
isOpen={killTarget !== null}
|
||||
onClose={() => setKillTarget(null)}
|
||||
onConfirm={() => {
|
||||
if (killTarget !== null) {
|
||||
killMutation.mutate(killTarget);
|
||||
setKillTarget(null);
|
||||
}
|
||||
}}
|
||||
resourceName={String(killTarget ?? '')}
|
||||
resourceType="query (PID)"
|
||||
/>
|
||||
</RefreshableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function MaintenanceSection() {
|
||||
return (
|
||||
<RefreshableCard title="Maintenance">
|
||||
<div className={styles.maintenanceGrid}>
|
||||
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
|
||||
VACUUM ANALYZE
|
||||
</button>
|
||||
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
|
||||
REINDEX
|
||||
</button>
|
||||
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
|
||||
Refresh Aggregates
|
||||
</button>
|
||||
</div>
|
||||
</RefreshableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ThresholdsSection({ thresholds }: { thresholds?: Thresholds }) {
|
||||
const [form, setForm] = useState<Thresholds | null>(null);
|
||||
const saveMutation = useSaveThresholds();
|
||||
const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
|
||||
|
||||
const current = form ?? thresholds;
|
||||
if (!current) return null;
|
||||
|
||||
function update(key: keyof Thresholds, value: number) {
|
||||
setForm((prev) => ({ ...(prev ?? thresholds!), [key]: value }));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form && !thresholds) return;
|
||||
const data = form ?? thresholds!;
|
||||
try {
|
||||
await saveMutation.mutateAsync(data);
|
||||
setStatus({ type: 'success', msg: 'Thresholds saved.' });
|
||||
setTimeout(() => setStatus(null), 3000);
|
||||
} catch {
|
||||
setStatus({ type: 'error', msg: 'Failed to save thresholds.' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<RefreshableCard title="Thresholds" collapsible defaultCollapsed>
|
||||
<div className={styles.thresholdGrid}>
|
||||
<div className={styles.thresholdField}>
|
||||
<label className={styles.thresholdLabel}>Pool Warning %</label>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.thresholdInput}
|
||||
value={current.poolWarningPercent}
|
||||
onChange={(e) => update('poolWarningPercent', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.thresholdField}>
|
||||
<label className={styles.thresholdLabel}>Pool Critical %</label>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.thresholdInput}
|
||||
value={current.poolCriticalPercent}
|
||||
onChange={(e) => update('poolCriticalPercent', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.thresholdField}>
|
||||
<label className={styles.thresholdLabel}>Query Warning (s)</label>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.thresholdInput}
|
||||
value={current.queryDurationWarningSeconds}
|
||||
onChange={(e) => update('queryDurationWarningSeconds', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.thresholdField}>
|
||||
<label className={styles.thresholdLabel}>Query Critical (s)</label>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.thresholdInput}
|
||||
value={current.queryDurationCriticalSeconds}
|
||||
onChange={(e) => update('queryDurationCriticalSeconds', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.thresholdActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnPrimary}
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save Thresholds'}
|
||||
</button>
|
||||
{status && (
|
||||
<span className={status.type === 'success' ? styles.successMsg : styles.errorMsg}>
|
||||
{status.msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</RefreshableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}m ${s % 60}s`;
|
||||
}
|
||||
Reference in New Issue
Block a user