Compare commits
18 Commits
abhi1693/f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73b01fcb25 | ||
|
|
7bca8c1ca2 | ||
|
|
efee334843 | ||
|
|
f7932d962a | ||
|
|
7adadd5c88 | ||
|
|
fab19ed5aa | ||
|
|
162b820880 | ||
|
|
fe6ff69d5c | ||
|
|
fad1e99329 | ||
|
|
3bc4dcaf55 | ||
|
|
74792593b2 | ||
|
|
2519af2395 | ||
|
|
9396be6fc0 | ||
|
|
42368f84bf | ||
|
|
f0ab3e315b | ||
|
|
7a0eb7b24a | ||
|
|
e85e714076 | ||
|
|
ea0149bd88 |
@@ -29,26 +29,28 @@ RUN uv sync --frozen --no-dev
|
||||
# --- runtime ---
|
||||
FROM base AS runtime
|
||||
|
||||
# Create non-root user before COPY so --chown can reference it.
|
||||
# Using COPY --chown avoids a slow recursive chown on overlay2 (docker/for-linux#388).
|
||||
RUN groupadd --system appgroup && useradd --system --gid appgroup --create-home appuser \
|
||||
&& chown appuser:appgroup /app
|
||||
|
||||
# Copy virtual environment from deps stage
|
||||
COPY --from=deps /app/.venv /app/.venv
|
||||
COPY --from=deps --chown=appuser:appgroup /app/.venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:${PATH}"
|
||||
|
||||
# Copy app source
|
||||
COPY backend/migrations ./migrations
|
||||
COPY backend/alembic.ini ./alembic.ini
|
||||
COPY backend/app ./app
|
||||
COPY --chown=appuser:appgroup backend/migrations ./migrations
|
||||
COPY --chown=appuser:appgroup backend/alembic.ini ./alembic.ini
|
||||
COPY --chown=appuser:appgroup backend/app ./app
|
||||
|
||||
# Copy provisioning templates.
|
||||
# In-repo these live at `backend/templates/`; runtime path is `/app/templates`.
|
||||
COPY backend/templates ./templates
|
||||
COPY --chown=appuser:appgroup backend/templates ./templates
|
||||
|
||||
# Copy worker scripts.
|
||||
# In-repo these live at `scripts/`; runtime path is `/app/scripts`.
|
||||
COPY scripts ./scripts
|
||||
COPY --chown=appuser:appgroup scripts ./scripts
|
||||
|
||||
# Run as non-root user
|
||||
RUN groupadd --system appgroup && useradd --system --gid appgroup appuser \
|
||||
&& chown -R appuser:appgroup /app
|
||||
USER appuser
|
||||
|
||||
# Default API port
|
||||
|
||||
@@ -50,6 +50,8 @@ Open:
|
||||
- Frontend: `http://localhost:${FRONTEND_PORT:-3000}`
|
||||
- Backend health: `http://localhost:${BACKEND_PORT:-8000}/healthz`
|
||||
|
||||
To have containers restart on failure and after host reboot, add `restart: unless-stopped` to the `db`, `redis`, `backend`, and `frontend` services in `compose.yml`, and ensure Docker is configured to start at boot.
|
||||
|
||||
### 3) Verify
|
||||
|
||||
```bash
|
||||
@@ -112,3 +114,65 @@ Typical setup (outline):
|
||||
- Ensure the frontend can reach the backend over the configured `NEXT_PUBLIC_API_URL`
|
||||
|
||||
This section is intentionally minimal until we standardize a recommended proxy (Caddy/Nginx/Traefik).
|
||||
|
||||
## Run at boot (local install)
|
||||
|
||||
If you installed Mission Control **without Docker** (e.g. using `install.sh` with "local" mode, or inside a VM where Docker is not used), the installer does not configure run-at-boot. You can start the stack after each reboot manually, or configure the OS to start it for you.
|
||||
|
||||
### Linux (systemd)
|
||||
|
||||
Use the example systemd units and instructions in [systemd/README.md](./systemd/README.md). In short:
|
||||
|
||||
1. Copy the unit files from `docs/deployment/systemd/` and replace `REPO_ROOT`, `BACKEND_PORT`, and `FRONTEND_PORT` with your paths and ports.
|
||||
2. Install the units under `~/.config/systemd/user/` (user) or `/etc/systemd/system/` (system).
|
||||
3. Enable and start the backend, frontend, and RQ worker services.
|
||||
|
||||
The RQ queue worker is required for gateway lifecycle (wake/check-in) and webhook delivery; run it as a separate unit.
|
||||
|
||||
### macOS (launchd)
|
||||
|
||||
LaunchAgents run at **user login**, not at machine boot. Use LaunchAgents so the backend, frontend, and worker run under your user and restart on failure. For true boot-time startup you would need LaunchDaemons or other configuration (not covered here).
|
||||
|
||||
1. Create a plist for each process under `~/Library/LaunchAgents/`, e.g. `com.openclaw.mission-control.backend.plist`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.openclaw.mission-control.backend</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/bin/env</string>
|
||||
<string>uv</string>
|
||||
<string>run</string>
|
||||
<string>uvicorn</string>
|
||||
<string>app.main:app</string>
|
||||
<string>--host</string>
|
||||
<string>0.0.0.0</string>
|
||||
<string>--port</string>
|
||||
<string>8000</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>REPO_ROOT/backend</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/opt/homebrew/bin:REPO_ROOT/backend/.venv/bin</string>
|
||||
</dict>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
Replace `REPO_ROOT` with the actual repo path. Ensure `uv` is on `PATH` (e.g. add `~/.local/bin` to the `PATH` in the plist). Load with:
|
||||
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.openclaw.mission-control.backend.plist
|
||||
```
|
||||
|
||||
2. Add similar plists for the frontend (`npm run start -- --hostname 0.0.0.0 --port 3000` in `REPO_ROOT/frontend`) and for the RQ worker (`uv run python ../scripts/rq worker` with `WorkingDirectory=REPO_ROOT/backend` and `ProgramArguments` pointing at `uv`, `run`, `python`, `../scripts/rq`, `worker`).
|
||||
|
||||
60
docs/deployment/systemd/README.md
Normal file
60
docs/deployment/systemd/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Systemd unit files (local install, run at boot)
|
||||
|
||||
Example systemd units for running Mission Control at boot when installed **without Docker** (e.g. local install in a VM).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Backend**: `uv`, Python 3.12+, and `backend/.env` configured (including `DATABASE_URL`, `RQ_REDIS_URL` if using the queue worker).
|
||||
- **Frontend**: Node.js 22+ and `frontend/.env` (e.g. `NEXT_PUBLIC_API_URL`).
|
||||
- **RQ worker**: Redis must be running and reachable; `backend/.env` must set `RQ_REDIS_URL` and `RQ_QUEUE_NAME` to match the backend API.
|
||||
|
||||
If you use Docker only for Postgres and/or Redis, start those first (e.g. `docker compose up -d db` and optionally Redis) or add `After=docker.service` and start the stack via a separate unit or script.
|
||||
|
||||
## Placeholders
|
||||
|
||||
Before installing, replace in each unit file:
|
||||
|
||||
- `REPO_ROOT` — absolute path to the Mission Control repo (e.g. `/home/user/openclaw-mission-control`). Must not contain spaces (systemd unit values do not support shell-style quoting).
|
||||
- `BACKEND_PORT` — backend port (default `8000`).
|
||||
- `FRONTEND_PORT` — frontend port (default `3000`).
|
||||
|
||||
Example (from repo root):
|
||||
|
||||
```bash
|
||||
REPO_ROOT="$(pwd)"
|
||||
for f in docs/deployment/systemd/openclaw-mission-control-*.service; do
|
||||
sed -e "s|REPO_ROOT|$REPO_ROOT|g" -e "s|BACKEND_PORT|8000|g" -e "s|FRONTEND_PORT|3000|g" "$f" \
|
||||
> "$(basename "$f")"
|
||||
done
|
||||
# Then copy the generated .service files to ~/.config/systemd/user/ or /etc/systemd/system/
|
||||
```
|
||||
|
||||
**User units** start at **user login** by default. To have services start at **machine boot** without logging in, enable lingering for your user: `loginctl enable-linger $USER`. Alternatively, use system-wide units in `/etc/systemd/system/` (see below).
|
||||
|
||||
## Install and enable
|
||||
|
||||
**User units** (recommended for single-user / VM):
|
||||
|
||||
```bash
|
||||
cp openclaw-mission-control-backend.service openclaw-mission-control-frontend.service openclaw-mission-control-rq-worker.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable openclaw-mission-control-backend openclaw-mission-control-frontend openclaw-mission-control-rq-worker
|
||||
systemctl --user start openclaw-mission-control-backend openclaw-mission-control-frontend openclaw-mission-control-rq-worker
|
||||
```
|
||||
|
||||
**System-wide** (e.g. under `/etc/systemd/system/`):
|
||||
|
||||
```bash
|
||||
sudo cp openclaw-mission-control-*.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now openclaw-mission-control-backend openclaw-mission-control-frontend openclaw-mission-control-rq-worker
|
||||
```
|
||||
|
||||
## Order
|
||||
|
||||
Start order is not strict between backend, frontend, and worker; all use `After=network-online.target`. Ensure Postgres (and Redis, if used) are running before or with the backend/worker (e.g. start Docker services first, or use system units for Postgres/Redis with the Mission Control units depending on them).
|
||||
|
||||
## Logs
|
||||
|
||||
- `journalctl --user -u openclaw-mission-control-backend -f` (or `sudo journalctl -u openclaw-mission-control-backend -f` for system units)
|
||||
- Same for `openclaw-mission-control-frontend` and `openclaw-mission-control-rq-worker`.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Mission Control backend (FastAPI) — example systemd unit for local install.
|
||||
# Copy to ~/.config/systemd/user/ or /etc/systemd/system/, then:
|
||||
# sed -e 's|REPO_ROOT|/path/to/openclaw-mission-control|g' -e 's|BACKEND_PORT|8000|g' -i openclaw-mission-control-backend.service
|
||||
# systemctl --user daemon-reload # or sudo systemctl daemon-reload
|
||||
# systemctl --user enable --now openclaw-mission-control-backend # or sudo systemctl enable --now ...
|
||||
#
|
||||
# Requires: uv in PATH (e.g. ~/.local/bin), backend/.env present.
|
||||
|
||||
[Unit]
|
||||
Description=Mission Control backend (FastAPI)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=REPO_ROOT/backend
|
||||
EnvironmentFile=-REPO_ROOT/backend/.env
|
||||
ExecStart=uv run uvicorn app.main:app --host 0.0.0.0 --port BACKEND_PORT
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,23 @@
|
||||
# Mission Control frontend (Next.js) — example systemd unit for local install.
|
||||
# Copy to ~/.config/systemd/user/ or /etc/systemd/system/, then:
|
||||
# sed -e 's|REPO_ROOT|/path/to/openclaw-mission-control|g' -e 's|FRONTEND_PORT|3000|g' -i openclaw-mission-control-frontend.service
|
||||
# systemctl --user daemon-reload # or sudo systemctl daemon-reload
|
||||
# systemctl --user enable --now openclaw-mission-control-frontend # or sudo systemctl enable --now ...
|
||||
#
|
||||
# Requires: Node.js/npm in PATH (e.g. from nvm or system install), frontend/.env present.
|
||||
|
||||
[Unit]
|
||||
Description=Mission Control frontend (Next.js)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=REPO_ROOT/frontend
|
||||
EnvironmentFile=-REPO_ROOT/frontend/.env
|
||||
ExecStart=npm run start -- --hostname 0.0.0.0 --port FRONTEND_PORT
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,24 @@
|
||||
# Mission Control RQ queue worker — example systemd unit for local install.
|
||||
# Processes lifecycle and webhook queue tasks; required for gateway wake/check-in and webhooks.
|
||||
# Copy to ~/.config/systemd/user/ or /etc/systemd/system/, then:
|
||||
# sed -e 's|REPO_ROOT|/path/to/openclaw-mission-control|g' -i openclaw-mission-control-rq-worker.service
|
||||
# systemctl --user daemon-reload # or sudo systemctl daemon-reload
|
||||
# systemctl --user enable --now openclaw-mission-control-rq-worker # or sudo systemctl enable --now ...
|
||||
#
|
||||
# Requires: uv in PATH, Redis reachable (RQ_REDIS_URL in backend/.env), backend/.env present.
|
||||
|
||||
[Unit]
|
||||
Description=Mission Control RQ queue worker
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=REPO_ROOT/backend
|
||||
EnvironmentFile=-REPO_ROOT/backend/.env
|
||||
ExecStart=uv run python ../scripts/rq worker
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -104,3 +104,29 @@ Actions:
|
||||
- gateway logs around bootstrap
|
||||
- worker logs around lifecycle events
|
||||
- agent `last_provision_error`, `wake_attempts`, `last_seen_at`
|
||||
|
||||
## Re-syncing auth tokens when Mission Control and OpenClaw have drifted
|
||||
|
||||
Mission Control stores a hash of each agent’s token and provisions OpenClaw by writing templates (e.g. `TOOLS.md`) that include `AUTH_TOKEN`. If the token on the gateway and the backend hash drift (e.g. after a reinstall, token change, or manual edit), heartbeats can fail with 401 and the agent may appear offline.
|
||||
|
||||
To re-sync:
|
||||
|
||||
1. Ensure Mission Control is running (API and queue worker).
|
||||
2. Run **template sync with token rotation** so the backend issues new agent tokens and rewrites `AUTH_TOKEN` into the gateway’s agent files.
|
||||
|
||||
**Via API (curl):**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/api/v1/gateways/GATEWAY_ID/templates/sync?rotate_tokens=true" \
|
||||
-H "Authorization: Bearer YOUR_LOCAL_AUTH_TOKEN"
|
||||
```
|
||||
|
||||
Replace `GATEWAY_ID` (from the Gateways list or gateway URL in the UI) and `YOUR_LOCAL_AUTH_TOKEN` with your local auth token.
|
||||
|
||||
**Via CLI (from repo root):**
|
||||
|
||||
```bash
|
||||
cd backend && uv run python scripts/sync_gateway_templates.py --gateway-id GATEWAY_ID --rotate-tokens
|
||||
```
|
||||
|
||||
After a successful sync, OpenClaw agents will have new `AUTH_TOKEN` values in their workspace files; the next heartbeat or bootstrap will use the new token. If the gateway was offline, trigger a wake/update from Mission Control so agents restart and pick up the new token.
|
||||
|
||||
@@ -31,16 +31,18 @@ ARG NEXT_PUBLIC_AUTH_MODE
|
||||
ENV NEXT_PUBLIC_API_URL=auto
|
||||
ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE}
|
||||
|
||||
COPY --from=builder /app/.next ./.next
|
||||
# Create non-root user before COPY so --chown can reference it.
|
||||
# Using COPY --chown avoids a slow recursive chown on overlay2 (docker/for-linux#388).
|
||||
RUN addgroup -S appgroup && adduser -S -G appgroup appuser \
|
||||
&& chown appuser:appgroup /app
|
||||
|
||||
COPY --from=builder --chown=appuser:appgroup /app/.next ./.next
|
||||
# `public/` is optional in Next.js apps; repo may not have it.
|
||||
# Avoid failing the build when the directory is absent.
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/next.config.ts ./next.config.ts
|
||||
COPY --from=builder --chown=appuser:appgroup /app/package.json ./package.json
|
||||
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=appuser:appgroup /app/next.config.ts ./next.config.ts
|
||||
|
||||
# Run as non-root user
|
||||
RUN addgroup -S appgroup && adduser -S -G appgroup appuser \
|
||||
&& chown -R appuser:appgroup /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
155
frontend/cypress/e2e/mobile_sidebar.cy.ts
Normal file
155
frontend/cypress/e2e/mobile_sidebar.cy.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
import { setupCommonPageTestHooks } from "../support/testHooks";
|
||||
|
||||
describe("/dashboard - mobile sidebar", () => {
|
||||
const apiBase = "**/api/v1";
|
||||
|
||||
setupCommonPageTestHooks(apiBase);
|
||||
|
||||
const emptySeries = {
|
||||
primary: { range: "7d", bucket: "day", points: [] },
|
||||
comparison: { range: "7d", bucket: "day", points: [] },
|
||||
};
|
||||
|
||||
function stubDashboardApis() {
|
||||
cy.intercept("GET", `${apiBase}/metrics/dashboard*`, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
generated_at: new Date().toISOString(),
|
||||
range: "7d",
|
||||
kpis: {
|
||||
inbox_tasks: 0,
|
||||
in_progress_tasks: 0,
|
||||
review_tasks: 0,
|
||||
done_tasks: 0,
|
||||
tasks_in_progress: 0,
|
||||
active_agents: 0,
|
||||
error_rate_pct: 0,
|
||||
median_cycle_time_hours_7d: null,
|
||||
},
|
||||
throughput: emptySeries,
|
||||
cycle_time: emptySeries,
|
||||
error_rate: emptySeries,
|
||||
wip: emptySeries,
|
||||
pending_approvals: { items: [], total: 0 },
|
||||
},
|
||||
}).as("dashboardMetrics");
|
||||
|
||||
cy.intercept("GET", `${apiBase}/boards*`, {
|
||||
statusCode: 200,
|
||||
body: { items: [], total: 0 },
|
||||
}).as("boardsList");
|
||||
|
||||
cy.intercept("GET", `${apiBase}/agents*`, {
|
||||
statusCode: 200,
|
||||
body: { items: [], total: 0 },
|
||||
}).as("agentsList");
|
||||
|
||||
cy.intercept("GET", `${apiBase}/activity*`, {
|
||||
statusCode: 200,
|
||||
body: { items: [], total: 0 },
|
||||
}).as("activityList");
|
||||
|
||||
cy.intercept("GET", `${apiBase}/gateways/status*`, {
|
||||
statusCode: 200,
|
||||
body: { gateways: [] },
|
||||
}).as("gatewaysStatus");
|
||||
|
||||
cy.intercept("GET", `${apiBase}/board-groups*`, {
|
||||
statusCode: 200,
|
||||
body: { items: [], total: 0 },
|
||||
}).as("boardGroupsList");
|
||||
}
|
||||
|
||||
function visitDashboardAuthenticated() {
|
||||
stubDashboardApis();
|
||||
cy.loginWithLocalAuth();
|
||||
cy.visit("/dashboard");
|
||||
cy.waitForAppLoaded();
|
||||
}
|
||||
|
||||
it("auth negative: signed-out user does not see hamburger button", () => {
|
||||
cy.visit("/dashboard");
|
||||
cy.contains("h1", /local authentication/i, { timeout: 30_000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.get('[aria-label="Toggle navigation"]').should("not.exist");
|
||||
});
|
||||
|
||||
it("mobile: hamburger button visible and sidebar hidden by default", () => {
|
||||
cy.viewport(375, 812);
|
||||
visitDashboardAuthenticated();
|
||||
|
||||
cy.get('[aria-label="Toggle navigation"]').should("be.visible");
|
||||
cy.get("[data-sidebar]").should("have.attr", "data-sidebar", "closed");
|
||||
cy.get("aside").should("not.be.visible");
|
||||
});
|
||||
|
||||
it("desktop: hamburger button hidden and sidebar always visible", () => {
|
||||
cy.viewport(1280, 800);
|
||||
visitDashboardAuthenticated();
|
||||
|
||||
cy.get('[aria-label="Toggle navigation"]').should("not.be.visible");
|
||||
cy.get("aside").should("be.visible");
|
||||
});
|
||||
|
||||
it("mobile: click hamburger opens sidebar and shows backdrop", () => {
|
||||
cy.viewport(375, 812);
|
||||
visitDashboardAuthenticated();
|
||||
|
||||
cy.get('[aria-label="Toggle navigation"]').click();
|
||||
|
||||
cy.get("[data-sidebar]").should("have.attr", "data-sidebar", "open");
|
||||
cy.get("aside").should("be.visible");
|
||||
cy.get('[data-cy="sidebar-backdrop"]').should("exist");
|
||||
});
|
||||
|
||||
it("mobile: click backdrop closes sidebar", () => {
|
||||
cy.viewport(375, 812);
|
||||
visitDashboardAuthenticated();
|
||||
|
||||
// Open sidebar first
|
||||
cy.get('[aria-label="Toggle navigation"]').click();
|
||||
cy.get("[data-sidebar]").should("have.attr", "data-sidebar", "open");
|
||||
|
||||
// Click the backdrop overlay
|
||||
cy.get('[data-cy="sidebar-backdrop"]').click({ force: true });
|
||||
|
||||
cy.get("[data-sidebar]").should("have.attr", "data-sidebar", "closed");
|
||||
cy.get("aside").should("not.be.visible");
|
||||
});
|
||||
|
||||
it("mobile: clicking a nav link closes sidebar", () => {
|
||||
cy.viewport(375, 812);
|
||||
visitDashboardAuthenticated();
|
||||
|
||||
// Open sidebar
|
||||
cy.get('[aria-label="Toggle navigation"]').click();
|
||||
cy.get("[data-sidebar]").should("have.attr", "data-sidebar", "open");
|
||||
cy.get("aside").should("be.visible");
|
||||
|
||||
// Click a navigation link inside the sidebar
|
||||
cy.get("aside").within(() => {
|
||||
cy.contains("a", "Boards").click();
|
||||
});
|
||||
|
||||
// Sidebar should close after navigation
|
||||
cy.get("[data-sidebar]").should("have.attr", "data-sidebar", "closed");
|
||||
});
|
||||
|
||||
it("mobile: pressing Escape closes sidebar", () => {
|
||||
cy.viewport(375, 812);
|
||||
visitDashboardAuthenticated();
|
||||
|
||||
// Open sidebar
|
||||
cy.get('[aria-label="Toggle navigation"]').click();
|
||||
cy.get("[data-sidebar]").should("have.attr", "data-sidebar", "open");
|
||||
|
||||
// Press Escape
|
||||
cy.get("body").type("{esc}");
|
||||
|
||||
cy.get("[data-sidebar]").should("have.attr", "data-sidebar", "closed");
|
||||
cy.get("aside").should("not.be.visible");
|
||||
});
|
||||
});
|
||||
@@ -1508,7 +1508,7 @@ export default function ActivityPage() {
|
||||
<DashboardSidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-slate-50">
|
||||
<div className="sticky top-0 z-30 border-b border-slate-200 bg-white">
|
||||
<div className="px-8 py-6">
|
||||
<div className="px-4 py-4 md:px-8 md:py-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1526,7 +1526,7 @@ export default function ActivityPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="p-4 md:p-8">
|
||||
{hasUnresolvedDeepLink ? (
|
||||
<div className="mb-4 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
Requested activity item is not in the current feed window yet.
|
||||
|
||||
@@ -162,13 +162,13 @@ export default function AgentDetailPage() {
|
||||
<SignedIn>
|
||||
<DashboardSidebar />
|
||||
{!isAdmin ? (
|
||||
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
|
||||
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-4 md:p-8">
|
||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-6 py-5 text-sm text-muted">
|
||||
Only organization owners and admins can access agents.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
|
||||
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-4 md:p-8">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
|
||||
|
||||
@@ -167,8 +167,8 @@ function GlobalApprovalsInner() {
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="p-6">
|
||||
<div className="h-[calc(100vh-160px)] min-h-[520px]">
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="h-[calc(100vh-160px)] min-h-[300px] sm:min-h-[520px]">
|
||||
<BoardApprovalsPanel
|
||||
boardId="global"
|
||||
approvals={approvals}
|
||||
|
||||
@@ -744,7 +744,7 @@ export default function BoardGroupDetailPage() {
|
||||
<DashboardSidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-slate-50">
|
||||
<div className="sticky top-0 z-30 border-b border-slate-200 bg-white shadow-sm">
|
||||
<div className="px-8 py-6">
|
||||
<div className="px-4 py-4 md:px-8 md:py-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
@@ -816,7 +816,7 @@ export default function BoardGroupDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<div className="mt-5 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -948,7 +948,7 @@ export default function BoardGroupDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="p-4 md:p-8">
|
||||
<div className="space-y-6">
|
||||
{heartbeatApplyError ? (
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700 shadow-sm">
|
||||
|
||||
@@ -33,9 +33,9 @@ export default function BoardApprovalsPage() {
|
||||
<SignedIn>
|
||||
<DashboardSidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="p-6">
|
||||
<div className="p-4 md:p-6">
|
||||
{boardId ? (
|
||||
<div className="h-[calc(100vh-160px)] min-h-[520px]">
|
||||
<div className="h-[calc(100vh-160px)] min-h-[300px] sm:min-h-[520px]">
|
||||
<BoardApprovalsPanel boardId={boardId} scrollable />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -3101,7 +3101,7 @@ export default function BoardDetailPage() {
|
||||
)}
|
||||
>
|
||||
<div className="sticky top-0 z-30 border-b border-slate-200 bg-white shadow-sm">
|
||||
<div className="px-8 py-6">
|
||||
<div className="px-4 py-4 md:px-8 md:py-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="mt-2 text-2xl font-semibold text-slate-900 tracking-tight">
|
||||
@@ -3237,9 +3237,9 @@ export default function BoardDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex gap-6 p-6">
|
||||
<div className="relative flex flex-col gap-4 p-4 md:flex-row md:gap-6 md:p-6">
|
||||
{isOrgAdmin ? (
|
||||
<aside className="flex h-full w-64 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<aside className="flex w-full flex-col rounded-xl border border-slate-200 bg-white shadow-sm md:h-full md:w-64">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
@@ -3680,12 +3680,12 @@ export default function BoardDetailPage() {
|
||||
) : null}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed right-0 top-0 z-50 h-full w-[max(760px,45vw)] max-w-[99vw] transform bg-white shadow-2xl transition-transform",
|
||||
"fixed right-0 top-0 z-50 h-full w-full max-w-[99vw] transform bg-white shadow-2xl transition-transform md:w-[max(760px,45vw)]",
|
||||
isDetailOpen ? "transform-none" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3 md:px-6 md:py-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Task detail
|
||||
@@ -3749,7 +3749,7 @@ export default function BoardDetailPage() {
|
||||
return (
|
||||
<div
|
||||
key={definition.id}
|
||||
className="grid grid-cols-[160px_1fr] gap-3"
|
||||
className="grid grid-cols-1 gap-2 sm:grid-cols-[160px_1fr] sm:gap-3"
|
||||
>
|
||||
<dt className="text-xs font-semibold text-slate-600">
|
||||
{definition.label || definition.field_key}
|
||||
@@ -3993,12 +3993,12 @@ export default function BoardDetailPage() {
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed right-0 top-0 z-50 h-full w-[560px] max-w-[96vw] transform border-l border-slate-200 bg-white shadow-2xl transition-transform",
|
||||
"fixed right-0 top-0 z-50 h-full w-full max-w-[96vw] transform border-l border-slate-200 bg-white shadow-2xl transition-transform md:w-[560px]",
|
||||
isChatOpen ? "transform-none" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3 md:px-6 md:py-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Board chat
|
||||
@@ -4055,12 +4055,12 @@ export default function BoardDetailPage() {
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed right-0 top-0 z-50 h-full w-[520px] max-w-[96vw] transform border-l border-slate-200 bg-white shadow-2xl transition-transform",
|
||||
"fixed right-0 top-0 z-50 h-full w-full max-w-[96vw] transform border-l border-slate-200 bg-white shadow-2xl transition-transform md:w-[520px]",
|
||||
isLiveFeedOpen ? "transform-none" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3 md:px-6 md:py-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Live feed
|
||||
|
||||
@@ -379,7 +379,7 @@ function TopMetricCard({
|
||||
: "bg-green-50 text-green-600";
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-4 md:p-6 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -423,7 +423,7 @@ function InfoBlock({
|
||||
rows: SummaryRow[];
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-4 md:p-6 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
|
||||
@@ -513,6 +513,8 @@ export default function DashboardPage() {
|
||||
enabled: Boolean(isSignedIn),
|
||||
refetchInterval: 15_000,
|
||||
refetchOnMount: "always",
|
||||
retry: 3,
|
||||
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -899,10 +901,10 @@ export default function DashboardPage() {
|
||||
<SignedIn>
|
||||
<DashboardSidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-slate-50">
|
||||
<div className="p-8">
|
||||
<div className="p-4 md:p-8">
|
||||
{metricsQuery.error ? (
|
||||
<div className="mb-4 rounded-lg border border-rose-300 bg-rose-50 p-3 text-sm text-rose-700">
|
||||
{metricsQuery.error.message}
|
||||
Load failed: {metricsQuery.error.message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -958,7 +960,7 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="mt-4 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<section className="mt-4 rounded-xl border border-slate-200 bg-white p-4 md:p-6 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Pending Approvals</h3>
|
||||
<Link
|
||||
@@ -1016,7 +1018,7 @@ export default function DashboardPage() {
|
||||
</section>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<section className="min-w-0 overflow-hidden rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<section className="min-w-0 overflow-hidden rounded-xl border border-slate-200 bg-white p-4 md:p-6 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Sessions</h3>
|
||||
<span className="text-xs text-slate-500">{formatCount(activeSessions)}</span>
|
||||
@@ -1082,7 +1084,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="min-w-0 overflow-hidden rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<section className="min-w-0 overflow-hidden rounded-xl border border-slate-200 bg-white p-4 md:p-6 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Recent Activity</h3>
|
||||
<Link
|
||||
|
||||
@@ -76,7 +76,7 @@ function InviteContent() {
|
||||
</header>
|
||||
|
||||
<main className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-16">
|
||||
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-8 shadow-sm">
|
||||
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 md:p-8 shadow-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
|
||||
Organization Invite
|
||||
@@ -158,7 +158,7 @@ export default function InvitePage() {
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-16">
|
||||
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-8 shadow-sm">
|
||||
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 md:p-8 shadow-sm">
|
||||
<div className="text-sm text-muted">Loading invite…</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -704,7 +704,7 @@ export default function OrganizationPage() {
|
||||
<DashboardSidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-slate-50">
|
||||
<div className="sticky top-0 z-30 border-b border-slate-200 bg-white">
|
||||
<div className="px-8 py-6">
|
||||
<div className="px-4 py-4 md:px-8 md:py-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-6">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -775,7 +775,7 @@ export default function OrganizationPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-8">
|
||||
<div className="px-4 py-4 md:px-8 md:py-8">
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-4">
|
||||
<div>
|
||||
|
||||
@@ -20,8 +20,8 @@ export function SignedOutPanel({
|
||||
buttonTestId,
|
||||
}: SignedOutPanelProps) {
|
||||
return (
|
||||
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
|
||||
<div className="col-span-1 md:col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-4 py-4 md:px-8 md:py-6 shadow-sm">
|
||||
<p className="text-sm text-slate-600">{message}</p>
|
||||
<SignInButton
|
||||
mode={mode}
|
||||
|
||||
@@ -130,7 +130,7 @@ export function BoardGroupsTable({
|
||||
stickyHeader={stickyHeader}
|
||||
emptyMessage={emptyMessage}
|
||||
rowClassName="transition hover:bg-slate-50"
|
||||
cellClassName="px-6 py-4 align-top"
|
||||
cellClassName="px-3 py-3 md:px-6 md:py-4 align-top"
|
||||
rowActions={
|
||||
showActions
|
||||
? {
|
||||
|
||||
@@ -162,7 +162,7 @@ export function BoardsTable({
|
||||
stickyHeader={stickyHeader}
|
||||
emptyMessage={emptyMessage}
|
||||
rowClassName="transition hover:bg-slate-50"
|
||||
cellClassName="px-6 py-4 align-top"
|
||||
cellClassName="px-3 py-3 md:px-6 md:py-4 align-top"
|
||||
rowActions={
|
||||
showActions
|
||||
? {
|
||||
|
||||
@@ -58,7 +58,7 @@ export function DashboardSidebar() {
|
||||
: "System degraded";
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-64 flex-col border-r border-slate-200 bg-white">
|
||||
<aside className="fixed inset-y-0 left-0 z-40 flex w-[280px] -translate-x-full flex-col border-r border-slate-200 bg-white pt-16 shadow-lg transition-transform duration-200 ease-in-out [[data-sidebar=open]_&]:translate-x-0 md:relative md:inset-auto md:z-auto md:w-[260px] md:translate-x-0 md:pt-0 md:shadow-none md:transition-none">
|
||||
<div className="flex-1 px-3 py-4">
|
||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Navigation
|
||||
|
||||
@@ -59,10 +59,10 @@ export function DataTable<TData>({
|
||||
stickyHeader = false,
|
||||
tableClassName = "w-full text-left text-sm",
|
||||
headerClassName,
|
||||
headerCellClassName = "px-6 py-3",
|
||||
headerCellClassName = "px-3 py-2 md:px-6 md:py-3",
|
||||
bodyClassName = "divide-y divide-slate-100",
|
||||
rowClassName = "hover:bg-slate-50",
|
||||
cellClassName = "px-6 py-4",
|
||||
cellClassName = "px-3 py-3 md:px-6 md:py-4",
|
||||
}: DataTableProps<TData>) {
|
||||
const resolvedRowActions = rowActions
|
||||
? (rowActions.actions ??
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DashboardPageLayout({
|
||||
headerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="px-8 py-6">
|
||||
<div className="px-4 py-4 md:px-8 md:py-6">
|
||||
{headerActions ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
@@ -103,7 +103,7 @@ export function DashboardPageLayout({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn("p-8", contentClassName)}>
|
||||
<div className={cn("p-4 md:p-8", contentClassName)}>
|
||||
{showAdminOnlyNotice ? (
|
||||
<AdminOnlyNotice message={adminOnlyMessage ?? ""} />
|
||||
) : (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { Menu, X } from "lucide-react";
|
||||
|
||||
import { SignedIn, useAuth } from "@/auth/clerk";
|
||||
|
||||
@@ -21,6 +22,14 @@ export function DashboardShell({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { isSignedIn } = useAuth();
|
||||
const isOnboardingPath = pathname === "/onboarding";
|
||||
const [sidebarState, setSidebarState] = useState({ open: false, path: pathname });
|
||||
// Close sidebar on navigation using React's "store info from previous
|
||||
// renders" pattern — conditional setState during render resets immediately
|
||||
// without extra commits, avoiding both set-state-in-effect and refs rules.
|
||||
if (sidebarState.path !== pathname) {
|
||||
setSidebarState({ open: false, path: pathname });
|
||||
}
|
||||
const sidebarOpen = sidebarState.open;
|
||||
|
||||
const meQuery = useGetMeApiV1UsersMeGet<
|
||||
getMeApiV1UsersMeGetResponse,
|
||||
@@ -68,22 +77,47 @@ export function DashboardShell({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = useCallback(
|
||||
() => setSidebarState((prev) => ({ open: !prev.open, path: pathname })),
|
||||
[pathname],
|
||||
);
|
||||
|
||||
// Dismiss sidebar on Escape
|
||||
useEffect(() => {
|
||||
if (!sidebarOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setSidebarState((prev) => ({ ...prev, open: false }));
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [sidebarOpen]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app text-strong">
|
||||
<header className="sticky top-0 z-40 border-b border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid grid-cols-[260px_1fr_auto] items-center gap-0 py-3">
|
||||
<div className="flex items-center px-6">
|
||||
<div className="min-h-screen bg-app text-strong" data-sidebar={sidebarOpen ? "open" : "closed"}>
|
||||
<header className="sticky top-0 z-50 border-b border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex items-center py-3">
|
||||
<div className="flex items-center px-4 md:px-6 md:w-[260px]">
|
||||
{isSignedIn ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mr-3 rounded-lg p-2 text-slate-600 hover:bg-slate-100 md:hidden"
|
||||
onClick={toggleSidebar}
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
) : null}
|
||||
<BrandMark />
|
||||
</div>
|
||||
<SignedIn>
|
||||
<div className="flex items-center">
|
||||
<div className="hidden md:flex flex-1 items-center">
|
||||
<div className="max-w-[220px]">
|
||||
<OrgSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</SignedIn>
|
||||
<SignedIn>
|
||||
<div className="flex items-center gap-3 px-6">
|
||||
<div className="ml-auto flex items-center gap-3 px-4 md:px-6">
|
||||
<div className="hidden text-right lg:block">
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{displayName}
|
||||
@@ -95,7 +129,18 @@ export function DashboardShell({ children }: { children: ReactNode }) {
|
||||
</SignedIn>
|
||||
</div>
|
||||
</header>
|
||||
<div className="grid min-h-[calc(100vh-64px)] grid-cols-[260px_1fr] bg-slate-50">
|
||||
|
||||
{/* Mobile sidebar overlay */}
|
||||
{sidebarOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/30 md:hidden"
|
||||
onClick={toggleSidebar}
|
||||
aria-hidden="true"
|
||||
data-cy="sidebar-backdrop"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-h-[calc(100vh-64px)] grid-cols-1 md:grid-cols-[260px_1fr] bg-slate-50">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
140
install.sh
140
install.sh
@@ -3,13 +3,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
if [[ "$SCRIPT_NAME" == "bash" || "$SCRIPT_NAME" == "-bash" ]]; then
|
||||
SCRIPT_NAME="install.sh"
|
||||
fi
|
||||
REPO_ROOT=""
|
||||
REPO_GIT_URL="${OPENCLAW_REPO_URL:-https://github.com/abhi1693/openclaw-mission-control.git}"
|
||||
REPO_CLONE_REF="${OPENCLAW_REPO_REF:-}"
|
||||
REPO_DIR_NAME="openclaw-mission-control"
|
||||
REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}"
|
||||
LOG_DIR="$STATE_DIR/openclaw-mission-control-install"
|
||||
|
||||
@@ -30,6 +24,7 @@ FORCE_LOCAL_AUTH_TOKEN=""
|
||||
FORCE_DB_MODE=""
|
||||
FORCE_DATABASE_URL=""
|
||||
FORCE_START_SERVICES=""
|
||||
FORCE_INSTALL_SERVICE=""
|
||||
|
||||
if [[ -t 0 ]]; then
|
||||
INTERACTIVE=1
|
||||
@@ -56,66 +51,6 @@ command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
repo_has_layout() {
|
||||
local dir="$1"
|
||||
[[ -f "$dir/Makefile" && -f "$dir/compose.yml" ]]
|
||||
}
|
||||
|
||||
resolve_script_directory() {
|
||||
local script_source=""
|
||||
local script_dir=""
|
||||
|
||||
if [[ -n "${BASH_SOURCE:-}" && -n "${BASH_SOURCE[0]:-}" ]]; then
|
||||
script_source="${BASH_SOURCE[0]}"
|
||||
elif [[ -n "${0:-}" && "${0:-}" != "bash" ]]; then
|
||||
script_source="$0"
|
||||
fi
|
||||
|
||||
[[ -n "$script_source" ]] || return 1
|
||||
|
||||
script_dir="$(cd -- "$(dirname -- "$script_source")" 2>/dev/null && pwd -P)" || return 1
|
||||
printf '%s\n' "$script_dir"
|
||||
}
|
||||
|
||||
bootstrap_repo_checkout() {
|
||||
local target_dir="$PWD/$REPO_DIR_NAME"
|
||||
|
||||
if ! command_exists git; then
|
||||
die "Git is required for one-line bootstrap installs. Install git and re-run."
|
||||
fi
|
||||
if [[ -e "$target_dir" ]]; then
|
||||
die "Cannot auto-clone into $target_dir because it already exists. Run ./install.sh from that repository or remove the directory."
|
||||
fi
|
||||
|
||||
info "Repository checkout not found. Cloning into $target_dir ..."
|
||||
if [[ -n "$REPO_CLONE_REF" ]]; then
|
||||
git clone --depth 1 --branch "$REPO_CLONE_REF" "$REPO_GIT_URL" "$target_dir"
|
||||
else
|
||||
git clone --depth 1 "$REPO_GIT_URL" "$target_dir"
|
||||
fi
|
||||
|
||||
REPO_ROOT="$target_dir"
|
||||
SCRIPT_NAME="install.sh"
|
||||
}
|
||||
|
||||
resolve_repo_root() {
|
||||
local script_dir=""
|
||||
|
||||
if script_dir="$(resolve_script_directory)"; then
|
||||
if repo_has_layout "$script_dir"; then
|
||||
REPO_ROOT="$script_dir"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if repo_has_layout "$PWD"; then
|
||||
REPO_ROOT="$PWD"
|
||||
return
|
||||
fi
|
||||
|
||||
bootstrap_repo_checkout
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $SCRIPT_NAME [options]
|
||||
@@ -131,6 +66,7 @@ Options:
|
||||
--db-mode <docker|external> Local mode only
|
||||
--database-url <url> Required when --db-mode external
|
||||
--start-services <yes|no> Local mode only
|
||||
--install-service Local mode only: install systemd user units for run at boot (Linux)
|
||||
-h, --help
|
||||
|
||||
If an option is omitted, the script prompts in interactive mode and uses defaults in non-interactive mode.
|
||||
@@ -220,6 +156,10 @@ parse_args() {
|
||||
FORCE_START_SERVICES="$2"
|
||||
shift 2
|
||||
;;
|
||||
--install-service)
|
||||
FORCE_INSTALL_SERVICE="yes"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -733,9 +673,52 @@ start_local_services() {
|
||||
)
|
||||
}
|
||||
|
||||
install_systemd_services() {
|
||||
local backend_port="$1"
|
||||
local frontend_port="$2"
|
||||
local systemd_user_dir
|
||||
systemd_user_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
local units_dir="$REPO_ROOT/docs/deployment/systemd"
|
||||
|
||||
if [[ "$REPO_ROOT" == *" "* ]]; then
|
||||
warn "REPO_ROOT must not contain spaces (systemd unit paths do not support it): $REPO_ROOT"
|
||||
return 1
|
||||
fi
|
||||
if [[ "$PLATFORM" != "linux" ]]; then
|
||||
info "Skipping systemd install (not Linux). For macOS run-at-boot see docs/deployment/README.md (launchd)."
|
||||
return 0
|
||||
fi
|
||||
if [[ ! -d "$units_dir" ]]; then
|
||||
warn "Systemd units dir not found: $units_dir"
|
||||
return 1
|
||||
fi
|
||||
for name in openclaw-mission-control-backend openclaw-mission-control-frontend openclaw-mission-control-rq-worker; do
|
||||
if [[ ! -f "$units_dir/$name.service" ]]; then
|
||||
warn "Unit file not found: $units_dir/$name.service"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p "$systemd_user_dir"
|
||||
for name in openclaw-mission-control-backend openclaw-mission-control-frontend openclaw-mission-control-rq-worker; do
|
||||
sed -e "s|REPO_ROOT|$REPO_ROOT|g" \
|
||||
-e "s|BACKEND_PORT|$backend_port|g" \
|
||||
-e "s|FRONTEND_PORT|$frontend_port|g" \
|
||||
"$units_dir/$name.service" > "$systemd_user_dir/$name.service"
|
||||
info "Installed $systemd_user_dir/$name.service"
|
||||
done
|
||||
if command_exists systemctl; then
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable openclaw-mission-control-backend openclaw-mission-control-frontend openclaw-mission-control-rq-worker
|
||||
info "Systemd user units enabled. Start with: systemctl --user start openclaw-mission-control-backend openclaw-mission-control-frontend openclaw-mission-control-rq-worker"
|
||||
else
|
||||
warn "systemctl not found; units were copied but not enabled."
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_repo_layout() {
|
||||
[[ -f "$REPO_ROOT/Makefile" ]] || die "Missing Makefile in expected repository root: $REPO_ROOT"
|
||||
[[ -f "$REPO_ROOT/compose.yml" ]] || die "Missing compose.yml in expected repository root: $REPO_ROOT"
|
||||
[[ -f "$REPO_ROOT/Makefile" ]] || die "Run $SCRIPT_NAME from repository root."
|
||||
[[ -f "$REPO_ROOT/compose.yml" ]] || die "Missing compose.yml in repository root."
|
||||
}
|
||||
|
||||
main() {
|
||||
@@ -750,7 +733,6 @@ main() {
|
||||
local database_url=""
|
||||
local start_services="yes"
|
||||
|
||||
resolve_repo_root
|
||||
cd "$REPO_ROOT"
|
||||
ensure_repo_layout
|
||||
parse_args "$@"
|
||||
@@ -879,14 +861,6 @@ main() {
|
||||
if [[ "$deployment_mode" == "docker" ]]; then
|
||||
ensure_file_from_example "$REPO_ROOT/backend/.env" "$REPO_ROOT/backend/.env.example"
|
||||
|
||||
# Docker services load backend/.env; ensure required runtime values are populated.
|
||||
upsert_env_value "$REPO_ROOT/backend/.env" "ENVIRONMENT" "prod"
|
||||
upsert_env_value "$REPO_ROOT/backend/.env" "AUTH_MODE" "local"
|
||||
upsert_env_value "$REPO_ROOT/backend/.env" "LOCAL_AUTH_TOKEN" "$local_auth_token"
|
||||
upsert_env_value "$REPO_ROOT/backend/.env" "CORS_ORIGINS" "http://$public_host:$frontend_port"
|
||||
upsert_env_value "$REPO_ROOT/backend/.env" "BASE_URL" "http://$public_host:$backend_port"
|
||||
upsert_env_value "$REPO_ROOT/backend/.env" "DB_AUTO_MIGRATE" "true"
|
||||
|
||||
upsert_env_value "$REPO_ROOT/.env" "DB_AUTO_MIGRATE" "true"
|
||||
|
||||
info "Starting production-like Docker stack..."
|
||||
@@ -954,6 +928,16 @@ SUMMARY
|
||||
wait_for_http "http://127.0.0.1:$frontend_port" "Frontend" 120 || true
|
||||
fi
|
||||
|
||||
if [[ -n "$FORCE_INSTALL_SERVICE" ]]; then
|
||||
if ! install_systemd_services "$backend_port" "$frontend_port"; then
|
||||
warn "Systemd service install failed; see errors above."
|
||||
die "Cannot continue when --install-service was requested and install failed."
|
||||
fi
|
||||
if [[ "$PLATFORM" == "linux" ]]; then
|
||||
info "Run at boot: systemd user units were installed and enabled. Start with: systemctl --user start openclaw-mission-control-backend openclaw-mission-control-frontend openclaw-mission-control-rq-worker"
|
||||
fi
|
||||
fi
|
||||
|
||||
cat <<SUMMARY
|
||||
|
||||
Bootstrap complete (Local mode).
|
||||
|
||||
Reference in New Issue
Block a user