Reconcile docs with reality; fix module graph, token compare, health #1
@@ -76,6 +76,11 @@ the live herdr instance and check.
|
||||
There is no container entrypoint script — `Dockerfile.api` execs
|
||||
`/app/orchestra` directly. Neither deployed file is the repo's
|
||||
`deploy/config.example.jsonc`.
|
||||
- Browser operator accounts live in `$ORCHESTRA_DATA/auth.db`. Create or reset
|
||||
one with `orchestra-user set -data /data -username NAME` while the API is
|
||||
stopped, or use the authenticated Settings screen. The old
|
||||
`ORCHESTRA_WEB_USERNAME`/`ORCHESTRA_WEB_PASSWORD_HASH` pair is accepted only
|
||||
for a one-time import into an empty database and should then be removed.
|
||||
- Two machines in the registry: `homesrv` (192.168.1.104) and `workpc`
|
||||
(192.168.1.105), each nominally running 3 herdrs (claude/codex/opencode).
|
||||
In practice **homesrv has no local herdr running** (connection refused on
|
||||
|
||||
@@ -391,3 +391,37 @@ work.
|
||||
|
||||
Sudo is not available in this sandbox, so the worker install, the restart, and
|
||||
the `worker.env` scrub remain operator steps.
|
||||
|
||||
## Browser operator database and UI refresh (2026-08-26)
|
||||
|
||||
The browser login no longer depends on an operator copying a bcrypt hash into
|
||||
deployment configuration. The live startup path in `cmd/orchestra/main.go`
|
||||
opens `$ORCHESTRA_DATA/auth.db` through `internal/authn`, refuses to serve with
|
||||
an empty operator database, and registers the database-backed session and
|
||||
account handlers before wrapping the mux with `authz.HTTPWithSessions`.
|
||||
|
||||
- `auth.db` is an embedded bbolt database created mode 0600. Passwords are
|
||||
bcrypt-hashed before the record is written; login also performs bcrypt for an
|
||||
unknown username to avoid an account-existence timing shortcut.
|
||||
- `orchestra-user set -data DIR -username NAME` reads and confirms a password
|
||||
from the terminal, creates the first operator, and resets an existing one.
|
||||
The Docker API image includes this helper. The authenticated Settings screen
|
||||
changes the current username/password and revokes every session for that
|
||||
identity.
|
||||
- An existing `ORCHESTRA_WEB_USERNAME`/`ORCHESTRA_WEB_PASSWORD_HASH` pair is
|
||||
imported once if and only if the database has no users. Once a user exists,
|
||||
those variables are ignored with an explicit startup log, so an old `.env`
|
||||
cannot overwrite a database credential.
|
||||
- The browser now gets its actual username from `GET /v1/ui/session`, renders
|
||||
it in the shell, and has a dedicated account page. The login view was rebuilt
|
||||
as a responsive desktop/mobile entry experience.
|
||||
- Frontend state drift was fixed at the same time: `needs_attention` and
|
||||
`in_review`, plus the three newer block reasons, are in the TypeScript model,
|
||||
board lanes, status colors, diagnosis copy, and filtering. The seven-state
|
||||
"All" board now has an explicit layout instead of falling back to one column.
|
||||
|
||||
Verified from the working tree after rebuilding the embedded assets:
|
||||
`go build ./...`, `go vet ./...`, and `go test ./...` all pass (21 test
|
||||
packages). The frontend TypeScript build passes, all five API-client tests
|
||||
pass, and Vite's production build emits the assets embedded by
|
||||
`internal/webui`.
|
||||
|
||||
+31
-8
@@ -8,17 +8,40 @@ coordinator deployment" below for the build that carries provenance. (The old
|
||||
|
||||
## Browser operator login
|
||||
|
||||
The browser UI requires `ORCHESTRA_WEB_USERNAME` and
|
||||
`ORCHESTRA_WEB_PASSWORD_HASH`. Generate a bcrypt hash without putting the
|
||||
password in shell history:
|
||||
Browser operators now live in the embedded `${ORCHESTRA_DATA}/auth.db`
|
||||
database. Passwords are bcrypt-hashed inside that database; no password hash
|
||||
belongs in `.env`.
|
||||
|
||||
For a new local data directory, create the first account while Orchestra is
|
||||
stopped. The command reads and confirms the password from the terminal:
|
||||
|
||||
```sh
|
||||
go run ./cmd/orchestra-password
|
||||
go run ./cmd/orchestra-user set -data ./data -username kami
|
||||
```
|
||||
|
||||
Set the emitted hash in the service environment along with the chosen
|
||||
username, then restart the coordinator. `ORCHESTRA_WEB_TOKEN` is not used by
|
||||
the browser UI anymore.
|
||||
For the Docker Compose deployment, the API image includes the same helper.
|
||||
Keep the API stopped while it opens the database, then use the existing data
|
||||
volume through Compose:
|
||||
|
||||
```sh
|
||||
docker compose stop orchestra-api
|
||||
docker compose run --rm --entrypoint /app/orchestra-user \
|
||||
orchestra-api set -data /data -username kami
|
||||
docker compose up -d orchestra-api
|
||||
```
|
||||
|
||||
After signing in, the Settings screen can change the username or password.
|
||||
Every browser session for that account is revoked after a credential change.
|
||||
To recover a forgotten password, stop the API and run `orchestra-user set`
|
||||
again for the same username. `orchestra-user list -data /data` lists usernames
|
||||
without exposing password hashes.
|
||||
|
||||
On the first start after upgrading, an empty auth database automatically
|
||||
imports the existing `ORCHESTRA_WEB_USERNAME` and
|
||||
`ORCHESTRA_WEB_PASSWORD_HASH` pair. Once the startup log confirms the import,
|
||||
remove both legacy values from `.env`; they are ignored whenever the database
|
||||
already contains an account. `ORCHESTRA_WEB_TOKEN` remains unused by the
|
||||
browser UI.
|
||||
|
||||
Build a worker for staging on workpc with:
|
||||
|
||||
@@ -44,7 +67,7 @@ credential: its `build` object is the coordinator provenance. `GET
|
||||
/v1/federation/workers` shows every worker's `build`, supported projects, and
|
||||
worker-local health without SSH.
|
||||
|
||||
Build both binaries with `deploy/build.sh`, which stamps them from one commit
|
||||
Build the coordinator and worker with `deploy/build.sh`, which stamps them from one commit
|
||||
and refuses a dirty tree. A burn-in run must never pair a new coordinator with
|
||||
an old worker, and matching revisions are how that is checked rather than
|
||||
assumed.
|
||||
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
#!/bin/sh
|
||||
# Build the coordinator and the worker from one commit, with one stamp, so a
|
||||
# Build the coordinator, worker, and operator-account helper from one commit,
|
||||
# with one stamp, so a
|
||||
# burn-in run can never pair a new coordinator with an old worker. Both
|
||||
# binaries then report the same revision at /v1/admin/diagnostics and in the
|
||||
# worker's registration, which is what makes deployed identity evidence rather
|
||||
@@ -23,4 +24,5 @@ flags="-s -w -X orchestra/internal/buildinfo.Revision=$rev -X orchestra/internal
|
||||
mkdir -p "$out"
|
||||
(cd "$tree" && go build -trimpath -ldflags="$flags" -o "$out/orchestra" ./cmd/orchestra)
|
||||
(cd "$tree" && go build -trimpath -ldflags="$flags" -o "$out/orchestra-worker" ./cmd/orchestra-worker)
|
||||
(cd "$tree" && go build -trimpath -ldflags="-s -w" -o "$out/orchestra-user" ./cmd/orchestra-user)
|
||||
echo "$rev"
|
||||
|
||||
@@ -139,12 +139,11 @@ ORCHESTRA_CONTEXT_WINDOW=200000
|
||||
# --- Bus authorization tokens (bearer auth per surface; a surface with no
|
||||
# token set has no auth requirement — set these once you have real clients) ---
|
||||
#ORCHESTRA_TUI_TOKEN=
|
||||
# Required: the service refuses to start without both. The browser UI's
|
||||
# task, lifecycle and approval controls are session-gated; it no longer
|
||||
# accepts a shared Web bearer token. Generate the bcrypt hash with:
|
||||
# go run ./cmd/orchestra-password
|
||||
ORCHESTRA_WEB_USERNAME=operator
|
||||
ORCHESTRA_WEB_PASSWORD_HASH=
|
||||
# Browser operators are stored in $ORCHESTRA_DATA/auth.db, not in this file.
|
||||
# With Orchestra stopped, create or reset one interactively with:
|
||||
# orchestra-user set -data /data -username kami
|
||||
# Existing ORCHESTRA_WEB_USERNAME + ORCHESTRA_WEB_PASSWORD_HASH values are
|
||||
# imported once only when auth.db contains no users, then should be removed.
|
||||
# Set when the UI is served over plain HTTP, so the session cookie can be
|
||||
# sent without Secure. Leave unset behind TLS.
|
||||
#ORCHESTRA_UI_INSECURE_COOKIE=1
|
||||
|
||||
+13
-13
@@ -17,20 +17,20 @@ describe('UI API client',()=>{
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/artifacts',expect.objectContaining({method:'POST',body:'report'}))
|
||||
})
|
||||
it('submits username and password to the browser login endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'operator'}),{status:200}))
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'operator'}),{status:200}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await expect(api.login('operator','not stored in the browser')).resolves.toEqual({username:'operator'})
|
||||
await expect(api.login('operator','not stored in the browser')).resolves.toEqual({username:'operator'})
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/session',expect.objectContaining({method:'POST',body:JSON.stringify({username:'operator',password:'not stored in the browser'})}))
|
||||
})
|
||||
it('treats a missing browser session as a normal signed-out state',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response('unauthorized',{status:401}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await expect(api.session()).resolves.toBeUndefined()
|
||||
})
|
||||
it('updates account credentials through the session-gated account endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'kami'}),{status:200}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await api.updateAccount({current_password:'old password',username:'kami',new_password:'new password'})
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/account',expect.objectContaining({method:'PUT'}))
|
||||
})
|
||||
it('treats a missing browser session as a normal signed-out state',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response('unauthorized',{status:401}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await expect(api.session()).resolves.toBeUndefined()
|
||||
})
|
||||
it('updates account credentials through the session-gated account endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'kami'}),{status:200}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await api.updateAccount({current_password:'old password',username:'kami',new_password:'new password'})
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/account',expect.objectContaining({method:'PUT'}))
|
||||
})
|
||||
})
|
||||
|
||||
+11
-12
@@ -50,10 +50,10 @@ async function upload(body: string) {
|
||||
}
|
||||
|
||||
async function session(): Promise<Account | undefined> {
|
||||
const response = await fetch('/v1/ui/session', { credentials: 'same-origin' })
|
||||
if (response.status === 401) return undefined
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.json() as Promise<Account>
|
||||
const response = await fetch('/v1/ui/session', { credentials: 'same-origin' })
|
||||
if (response.status === 401) return undefined
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.json() as Promise<Account>
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
@@ -64,7 +64,7 @@ async function login(username: string, password: string) {
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.json() as Promise<Account>
|
||||
return response.json() as Promise<Account>
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
@@ -76,15 +76,14 @@ async function logout() {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
session,
|
||||
session,
|
||||
login,
|
||||
logout,
|
||||
account: () => request<Account>('/v1/ui/account'),
|
||||
updateAccount: (body: { current_password: string; username: string; new_password?: string }) =>
|
||||
request<Account>('/v1/ui/account', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
updateAccount: (body: { current_password: string; username: string; new_password?: string }) =>
|
||||
request<Account>('/v1/ui/account', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
overview: () => request<Overview>('/v1/ui/overview'),
|
||||
detail: (id: string) => request<Detail>(`/v1/ui/tasks/${id}`),
|
||||
artifact: (ref: string) => text(`/v1/ui/artifacts/${ref}`),
|
||||
|
||||
+189
-163
@@ -19,7 +19,7 @@ import {
|
||||
} from '@tanstack/react-query'
|
||||
import { api } from './api/client'
|
||||
import type {
|
||||
Account,
|
||||
Account,
|
||||
Action,
|
||||
BlockReason,
|
||||
Capture,
|
||||
@@ -46,9 +46,9 @@ const historyStates: TaskState[] = ['completed', 'failed']
|
||||
const stateLabel: Record<TaskState, string> = {
|
||||
queued: 'Queued',
|
||||
leased: 'In session',
|
||||
needs_attention: 'Needs attention',
|
||||
needs_attention: 'Needs attention',
|
||||
blocked: 'Blocked',
|
||||
in_review: 'In review',
|
||||
in_review: 'In review',
|
||||
completed: 'Complete',
|
||||
failed: 'Failed',
|
||||
}
|
||||
@@ -61,9 +61,9 @@ const blockLabel: Record<BlockReason, string> = {
|
||||
handoff_validation: 'Handoff validation',
|
||||
operator_block: 'Operator block',
|
||||
system_error: 'System error',
|
||||
trajectory_gate: 'Plan approval',
|
||||
human_decision: 'Decision needed',
|
||||
operator_required: 'Operator required',
|
||||
trajectory_gate: 'Plan approval',
|
||||
human_decision: 'Decision needed',
|
||||
operator_required: 'Operator required',
|
||||
unknown: 'Unknown',
|
||||
}
|
||||
|
||||
@@ -100,8 +100,8 @@ type IconName =
|
||||
| 'refresh'
|
||||
| 'search'
|
||||
| 'server'
|
||||
| 'settings'
|
||||
| 'shield'
|
||||
| 'settings'
|
||||
| 'shield'
|
||||
| 'terminal'
|
||||
| 'users'
|
||||
| 'x'
|
||||
@@ -169,13 +169,13 @@ function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
|
||||
<path d="M7 7h.01M7 17h.01" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
|
||||
</>
|
||||
),
|
||||
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Zm-3-10 2 2 4-5" />,
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
|
||||
</>
|
||||
),
|
||||
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Zm-3-10 2 2 4-5" />,
|
||||
terminal: (
|
||||
<>
|
||||
<path d="m4 6 5 5-5 5M11 18h9" />
|
||||
@@ -256,8 +256,8 @@ function humanize(value: string) {
|
||||
}
|
||||
|
||||
function initials(username: string) {
|
||||
const parts = username.trim().split(/[\s._-]+/).filter(Boolean)
|
||||
return (parts.length > 1 ? `${parts[0][0]}${parts[1][0]}` : username.slice(0, 2)).toUpperCase()
|
||||
const parts = username.trim().split(/[\s._-]+/).filter(Boolean)
|
||||
return (parts.length > 1 ? `${parts[0][0]}${parts[1][0]}` : username.slice(0, 2)).toUpperCase()
|
||||
}
|
||||
|
||||
function sessionFor(task: Task, overview: Overview) {
|
||||
@@ -284,13 +284,13 @@ function taskExplanation(task: Task, overview: Overview) {
|
||||
if (session?.blocker) return session.blocker
|
||||
return session?.capture ? 'Harness is publishing live output' : 'Leased · capture unavailable'
|
||||
}
|
||||
if (task.state === 'needs_attention') {
|
||||
return task.blocker || 'The current lease is retained while an operator investigates'
|
||||
}
|
||||
if (task.state === 'needs_attention') {
|
||||
return task.blocker || 'The current lease is retained while an operator investigates'
|
||||
}
|
||||
if (task.state === 'blocked') {
|
||||
return task.blocker || 'No blocker detail was retained for this task'
|
||||
}
|
||||
if (task.state === 'in_review') return 'Submitted change is waiting for human review'
|
||||
if (task.state === 'in_review') return 'Submitted change is waiting for human review'
|
||||
if (task.state === 'failed') return task.last_error || 'Review the failure before retrying'
|
||||
return 'Work and completion evidence retained'
|
||||
}
|
||||
@@ -344,7 +344,7 @@ function CommandPalette({ close }: { close: () => void }) {
|
||||
['board', 'Open dispatch board', '', 'grid'],
|
||||
['new', 'Create a new task', 'N', 'plus'],
|
||||
['workers', 'Open worker pool', '', 'server'],
|
||||
['settings', 'Open account settings', '', 'settings'],
|
||||
['settings', 'Open account settings', '', 'settings'],
|
||||
['refresh', 'Refresh live data', 'R', 'refresh'],
|
||||
] as const,
|
||||
[],
|
||||
@@ -364,7 +364,7 @@ function CommandPalette({ close }: { close: () => void }) {
|
||||
const choose = (id: string) => {
|
||||
if (id === 'board') navigate('/')
|
||||
if (id === 'workers') navigate('/workers')
|
||||
if (id === 'settings') navigate('/settings')
|
||||
if (id === 'settings') navigate('/settings')
|
||||
if (id === 'new') {
|
||||
navigate('/')
|
||||
window.setTimeout(() => window.dispatchEvent(new Event('orchestra:new-task')), 0)
|
||||
@@ -451,8 +451,8 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc
|
||||
? 'Dispatch board'
|
||||
: location.pathname === '/workers'
|
||||
? 'Worker pool'
|
||||
: location.pathname === '/settings'
|
||||
? 'Account settings'
|
||||
: location.pathname === '/settings'
|
||||
? 'Account settings'
|
||||
: location.pathname.startsWith('/artifacts/')
|
||||
? 'Evidence artifact'
|
||||
: 'Task record'
|
||||
@@ -501,10 +501,10 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc
|
||||
<span>Workers</span>
|
||||
<b className="nav-count">{onlineWorkers}/{workers.length}</b>
|
||||
</NavLink>
|
||||
<NavLink to="/settings">
|
||||
<Icon name="settings" />
|
||||
<span>Settings</span>
|
||||
</NavLink>
|
||||
<NavLink to="/settings">
|
||||
<Icon name="settings" />
|
||||
<span>Settings</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="sidebar-status">
|
||||
<span className={onlineWorkers ? 'signal online' : 'signal'} />
|
||||
@@ -538,17 +538,17 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc
|
||||
aria-expanded={accountOpen}
|
||||
onClick={() => setAccountOpen((open) => !open)}
|
||||
>
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span className="account-label">{account.username}</span>
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span className="account-label">{account.username}</span>
|
||||
</button>
|
||||
{accountOpen && (
|
||||
<div className="account-menu">
|
||||
<div className="account-menu-user">
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span><b>{account.username}</b><small>Operator account</small></span>
|
||||
</div>
|
||||
<Link to="/settings" onClick={() => setAccountOpen(false)}><Icon name="settings" size={15} /> Account settings</Link>
|
||||
<button type="button" onClick={onLogout}><Icon name="arrow-left" size={15} /> Sign out</button>
|
||||
<div className="account-menu-user">
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span><b>{account.username}</b><small>Operator account</small></span>
|
||||
</div>
|
||||
<Link to="/settings" onClick={() => setAccountOpen(false)}><Icon name="settings" size={15} /> Account settings</Link>
|
||||
<button type="button" onClick={onLogout}><Icon name="arrow-left" size={15} /> Sign out</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -941,9 +941,9 @@ function OverviewPage() {
|
||||
const projects = [...new Set(data.tasks.map((task) => task.project).filter(Boolean))].sort()
|
||||
const pendingApprovals = sessions.filter((session) => session.pending_approval)
|
||||
const approvalTask = pendingApprovals.find((session) => session.capture?.task_id)?.capture?.task_id
|
||||
const inSession = data.tasks.filter((task) => task.state === 'leased' || task.state === 'needs_attention').length
|
||||
const inSession = data.tasks.filter((task) => task.state === 'leased' || task.state === 'needs_attention').length
|
||||
const queued = data.tasks.filter((task) => task.state === 'queued').length
|
||||
const attention = data.tasks.filter((task) => task.state === 'needs_attention' || task.state === 'blocked' || task.state === 'failed').length
|
||||
const attention = data.tasks.filter((task) => task.state === 'needs_attention' || task.state === 'blocked' || task.state === 'failed').length
|
||||
const history = data.tasks.filter((task) => historyStates.includes(task.state)).length
|
||||
const onlineWorkers = data.workers.filter((worker) => worker.online).length
|
||||
const term = search.trim().toLowerCase()
|
||||
@@ -1267,14 +1267,14 @@ function TaskDiagnosis({ detail }: { detail: Detail }) {
|
||||
const observed = evidence?.captured_at || evidence?.checked_at || detail.session?.capture?.at
|
||||
const title = approval
|
||||
? 'Waiting for operator approval'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
? blockLabel[detail.task.block_reason || 'unknown']
|
||||
: detail.task.state === 'leased'
|
||||
? detail.session?.capture ? 'Agent session is active' : 'Session capture is unavailable'
|
||||
: stateLabel[detail.task.state]
|
||||
const explanation = approval
|
||||
? 'The harness is paused at a permission boundary. Review the exact request below.'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
? detail.task.blocker || 'No blocker detail was retained.'
|
||||
: detail.task.state === 'leased'
|
||||
? detail.session?.capture
|
||||
@@ -1282,8 +1282,8 @@ function TaskDiagnosis({ detail }: { detail: Detail }) {
|
||||
: detail.session?.blocker || 'The lease exists, but Orchestra cannot read current pane output.'
|
||||
: detail.task.state === 'queued'
|
||||
? 'This task is eligible for routing when a compatible worker has capacity.'
|
||||
: detail.task.state === 'in_review'
|
||||
? 'The implementation was submitted and is waiting for the bound human review.'
|
||||
: detail.task.state === 'in_review'
|
||||
? 'The implementation was submitted and is waiting for the bound human review.'
|
||||
: 'This is a terminal task record with retained evidence.'
|
||||
|
||||
return (
|
||||
@@ -1581,114 +1581,116 @@ function Workers() {
|
||||
}
|
||||
|
||||
function Settings({ account, onCredentialsChanged }: { account: Account; onCredentialsChanged: (username: string) => void }) {
|
||||
const [username, setUsername] = useState(account.username)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.updateAccount({
|
||||
current_password: currentPassword,
|
||||
username: username.trim(),
|
||||
...(newPassword ? { new_password: newPassword } : {}),
|
||||
}),
|
||||
onSuccess: (updated) => onCredentialsChanged(updated.username),
|
||||
})
|
||||
const usernameChanged = username.trim() !== account.username
|
||||
const changed = usernameChanged || !!newPassword
|
||||
const passwordLongEnough = newPassword.length >= 10
|
||||
const passwordWithinLimit = new TextEncoder().encode(newPassword).length <= 72
|
||||
const passwordsMatch = newPassword === confirmation
|
||||
const [username, setUsername] = useState(account.username)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.updateAccount({
|
||||
current_password: currentPassword,
|
||||
username: username.trim(),
|
||||
...(newPassword ? { new_password: newPassword } : {}),
|
||||
}),
|
||||
onSuccess: (updated) => onCredentialsChanged(updated.username),
|
||||
})
|
||||
const usernameChanged = username.trim() !== account.username
|
||||
const changed = usernameChanged || !!newPassword
|
||||
const passwordLongEnough = Array.from(newPassword).length >= 10
|
||||
const passwordWithinLimit = new TextEncoder().encode(newPassword).length <= 72
|
||||
const passwordsMatch = newPassword === confirmation
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setFormError('')
|
||||
if (!username.trim()) {
|
||||
setFormError('Username cannot be empty.')
|
||||
return
|
||||
}
|
||||
if (!changed) {
|
||||
setFormError('Change the username or enter a new password first.')
|
||||
return
|
||||
}
|
||||
if (!currentPassword) {
|
||||
setFormError('Enter your current password to authorize this change.')
|
||||
return
|
||||
}
|
||||
if (newPassword && (!passwordLongEnough || !passwordWithinLimit || !passwordsMatch)) {
|
||||
setFormError(!passwordsMatch ? 'The new passwords do not match.' : 'Use a password between 10 and 72 bytes.')
|
||||
return
|
||||
}
|
||||
mutation.mutate()
|
||||
}
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setFormError('')
|
||||
if (!username.trim()) {
|
||||
setFormError('Username cannot be empty.')
|
||||
return
|
||||
}
|
||||
if (!changed) {
|
||||
setFormError('Change the username or enter a new password first.')
|
||||
return
|
||||
}
|
||||
if (!currentPassword) {
|
||||
setFormError('Enter your current password to authorize this change.')
|
||||
return
|
||||
}
|
||||
if (newPassword && (!passwordLongEnough || !passwordWithinLimit || !passwordsMatch)) {
|
||||
setFormError(!passwordsMatch ? 'The new passwords do not match.' : 'Use a password between 10 and 72 bytes.')
|
||||
return
|
||||
}
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page settings-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Operator identity</span>
|
||||
<h1>Your account.</h1>
|
||||
<p>Change the credentials you use for this control plane. No environment hash is involved.</p>
|
||||
</div>
|
||||
</header>
|
||||
return (
|
||||
<main className="page settings-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Operator identity</span>
|
||||
<h1>Your account.</h1>
|
||||
<p>Change the credentials you use for this control plane. No environment hash is involved.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="settings-layout">
|
||||
<aside className="profile-card">
|
||||
<span className="profile-avatar">{initials(account.username)}</span>
|
||||
<h2>{account.username}</h2>
|
||||
<p>Full-control operator</p>
|
||||
<dl>
|
||||
<div><dt>Account created</dt><dd>{date(account.created_at)}</dd></div>
|
||||
<div><dt>Credentials updated</dt><dd>{date(account.updated_at)}</dd></div>
|
||||
</dl>
|
||||
<div className="database-badge"><Icon name="shield" size={17} /><span><b>Local credential database</b><small>Password hashes stay inside Orchestra’s data volume.</small></span></div>
|
||||
</aside>
|
||||
<div className="settings-layout">
|
||||
<aside className="profile-card">
|
||||
<span className="profile-avatar">{initials(account.username)}</span>
|
||||
<h2>{account.username}</h2>
|
||||
<p>Full-control operator</p>
|
||||
<dl>
|
||||
<div><dt>Account created</dt><dd>{date(account.created_at)}</dd></div>
|
||||
<div><dt>Credentials updated</dt><dd>{date(account.updated_at)}</dd></div>
|
||||
</dl>
|
||||
<div className="database-badge"><Icon name="shield" size={17} /><span><b>Local credential database</b><small>Password hashes stay inside Orchestra’s data volume.</small></span></div>
|
||||
</aside>
|
||||
|
||||
<section className="settings-card">
|
||||
<header>
|
||||
<span className="settings-icon"><Icon name="settings" /></span>
|
||||
<div><h2>Sign-in credentials</h2><p>Changing either field signs out every browser using this account.</p></div>
|
||||
</header>
|
||||
<form onSubmit={submit} noValidate>
|
||||
<label htmlFor="account-username">Username</label>
|
||||
<input id="account-username" autoComplete="username" value={username} onChange={(event) => { setUsername(event.target.value); setFormError('') }} />
|
||||
<section className="settings-card">
|
||||
<header>
|
||||
<span className="settings-icon"><Icon name="settings" /></span>
|
||||
<div><h2>Sign-in credentials</h2><p>Changing either field signs out every browser using this account.</p></div>
|
||||
</header>
|
||||
<form onSubmit={submit} noValidate>
|
||||
<label htmlFor="account-username">Username</label>
|
||||
<input id="account-username" autoComplete="username" value={username} onChange={(event) => { setUsername(event.target.value); setFormError('') }} />
|
||||
|
||||
<div className="settings-divider"><span>Optional password change</span></div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="account-new-password">New password
|
||||
<div className="password-field">
|
||||
<input id="account-new-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={newPassword} onChange={(event) => { setNewPassword(event.target.value); setFormError('') }} placeholder="Leave blank to keep it" />
|
||||
<button type="button" onClick={() => setVisible((value) => !value)}>{visible ? 'Hide' : 'Show'}</button>
|
||||
</div>
|
||||
</label>
|
||||
<label htmlFor="account-confirm-password">Confirm new password
|
||||
<input id="account-confirm-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={confirmation} onChange={(event) => { setConfirmation(event.target.value); setFormError('') }} placeholder="Repeat new password" />
|
||||
</label>
|
||||
</div>
|
||||
{newPassword && (
|
||||
<div className="password-rules" aria-live="polite">
|
||||
<span className={passwordLongEnough ? 'met' : ''}><Icon name="check" size={13} /> 10+ characters</span>
|
||||
<span className={passwordWithinLimit ? 'met' : ''}><Icon name="check" size={13} /> 72 bytes or fewer</span>
|
||||
<span className={passwordsMatch && !!confirmation ? 'met' : ''}><Icon name="check" size={13} /> Passwords match</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-divider"><span>Optional password change</span></div>
|
||||
<div className="form-row">
|
||||
<div className="settings-field">
|
||||
<label htmlFor="account-new-password">New password</label>
|
||||
<div className="password-field">
|
||||
<input id="account-new-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={newPassword} onChange={(event) => { setNewPassword(event.target.value); setFormError('') }} placeholder="Leave blank to keep it" />
|
||||
<button type="button" onClick={() => setVisible((value) => !value)}>{visible ? 'Hide' : 'Show'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<label htmlFor="account-confirm-password">Confirm new password</label>
|
||||
<input id="account-confirm-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={confirmation} onChange={(event) => { setConfirmation(event.target.value); setFormError('') }} placeholder="Repeat new password" />
|
||||
</div>
|
||||
</div>
|
||||
{newPassword && (
|
||||
<div className="password-rules" aria-live="polite">
|
||||
<span className={passwordLongEnough ? 'met' : ''}><Icon name="check" size={13} /> 10+ characters</span>
|
||||
<span className={passwordWithinLimit ? 'met' : ''}><Icon name="check" size={13} /> 72 bytes or fewer</span>
|
||||
<span className={passwordsMatch && !!confirmation ? 'met' : ''}><Icon name="check" size={13} /> Passwords match</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="current-password-block">
|
||||
<label htmlFor="account-current-password">Current password</label>
|
||||
<p>Required to save account changes.</p>
|
||||
<input id="account-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => { setCurrentPassword(event.target.value); setFormError('') }} />
|
||||
</div>
|
||||
{(formError || mutation.error) && <p className="form-error" role="alert"><Icon name="alert" size={15} /> {formError || errorMessage(mutation.error)}</p>}
|
||||
<footer className="settings-actions">
|
||||
<span>You’ll sign in again after saving.</span>
|
||||
<button type="submit" disabled={mutation.isPending || !changed}>{mutation.isPending ? 'Saving…' : 'Save credentials'}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
<div className="current-password-block">
|
||||
<label htmlFor="account-current-password">Current password</label>
|
||||
<p>Required to save account changes.</p>
|
||||
<input id="account-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => { setCurrentPassword(event.target.value); setFormError('') }} />
|
||||
</div>
|
||||
{(formError || mutation.error) && <p className="form-error" role="alert"><Icon name="alert" size={15} /> {formError || errorMessage(mutation.error)}</p>}
|
||||
<footer className="settings-actions">
|
||||
<span>You’ll sign in again after saving.</span>
|
||||
<button type="submit" disabled={mutation.isPending || !changed}>{mutation.isPending ? 'Saving…' : 'Save credentials'}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Artifact() {
|
||||
@@ -1721,7 +1723,7 @@ function Artifact() {
|
||||
)
|
||||
}
|
||||
|
||||
function Login({ onAuthenticated, message }: { onAuthenticated: () => void; message?: string }) {
|
||||
function Login({ onAuthenticated, message }: { onAuthenticated: (account: Account) => void; message?: string }) {
|
||||
const usernameInput = useRef<HTMLInputElement>(null)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -1742,9 +1744,9 @@ function Login({ onAuthenticated, message }: { onAuthenticated: () => void; mess
|
||||
setError('')
|
||||
setPending(true)
|
||||
try {
|
||||
await api.login(username, password)
|
||||
const account = await api.login(username, password)
|
||||
setPassword('')
|
||||
onAuthenticated()
|
||||
onAuthenticated(account)
|
||||
} catch {
|
||||
setError('That username or password was not accepted. Check both and try again.')
|
||||
} finally {
|
||||
@@ -1755,7 +1757,24 @@ function Login({ onAuthenticated, message }: { onAuthenticated: () => void; mess
|
||||
return (
|
||||
<main className="login-page">
|
||||
<div className="login-grid" aria-hidden="true" />
|
||||
<section className="login-card" aria-labelledby="login-title">
|
||||
<div className="login-layout">
|
||||
<section className="login-showcase" aria-label="Orchestra overview">
|
||||
<div className="showcase-brand"><Logo /><span><b>Orchestra</b><small>Unattended work, under control</small></span></div>
|
||||
<div className="showcase-copy">
|
||||
<span className="eyebrow">Operator console</span>
|
||||
<h2>Keep every agent<br />on the same score.</h2>
|
||||
<p>Dispatch work, inspect live sessions, resolve decisions, and retain the evidence that brought each task home.</p>
|
||||
</div>
|
||||
<div className="showcase-flow" aria-hidden="true">
|
||||
<span><i className="flow-dot queued" /> Queue</span><b />
|
||||
<span><i className="flow-dot active" /> Agent</span><b />
|
||||
<span><i className="flow-dot review" /> Review</span><b />
|
||||
<span><i className="flow-dot done" /> Done</span>
|
||||
</div>
|
||||
<footer><span className="signal online" /> Control plane ready</footer>
|
||||
</section>
|
||||
|
||||
<section className="login-card" aria-labelledby="login-title">
|
||||
<header className="login-brand"><Logo /><span><b>Orchestra</b><small>Control plane</small></span></header>
|
||||
<div className="login-heading">
|
||||
<span className="eyebrow">Operator access</span>
|
||||
@@ -1799,10 +1818,11 @@ function Login({ onAuthenticated, message }: { onAuthenticated: () => void; mess
|
||||
</button>
|
||||
</form>
|
||||
<footer className="login-security">
|
||||
<span><Icon name="check" size={15} /></span>
|
||||
<p>Credentials are verified server-side. This browser receives an HttpOnly session cookie that expires after 12 hours.</p>
|
||||
<span><Icon name="shield" size={15} /></span>
|
||||
<p>Your password is verified against Orchestra’s local operator database. The browser receives only a 12-hour HttpOnly session cookie.</p>
|
||||
</footer>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1820,13 +1840,14 @@ function NotFound() {
|
||||
)
|
||||
}
|
||||
|
||||
function RoutesApp({ onLogout }: { onLogout: () => void }) {
|
||||
function RoutesApp({ account, onLogout, onCredentialsChanged }: { account: Account; onLogout: () => void; onCredentialsChanged: (username: string) => void }) {
|
||||
return (
|
||||
<Shell onLogout={onLogout}>
|
||||
<Shell account={account} onLogout={onLogout}>
|
||||
<Routes>
|
||||
<Route path="/" element={<OverviewPage />} />
|
||||
<Route path="/tasks/:taskID" element={<TaskDetail />} />
|
||||
<Route path="/workers" element={<Workers />} />
|
||||
<Route path="/settings" element={<Settings account={account} onCredentialsChanged={onCredentialsChanged} />} />
|
||||
<Route path="/artifacts/:ref" element={<Artifact />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
@@ -1835,7 +1856,7 @@ function RoutesApp({ onLogout }: { onLogout: () => void }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [ready, setReady] = useState(false)
|
||||
const [account, setAccount] = useState<Account>()
|
||||
const [checking, setChecking] = useState(true)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
@@ -1843,13 +1864,13 @@ function App() {
|
||||
const unauthorized = () => {
|
||||
client.clear()
|
||||
setMessage('Your browser session expired. Sign in again to continue.')
|
||||
setReady(false)
|
||||
setAccount(undefined)
|
||||
setChecking(false)
|
||||
}
|
||||
window.addEventListener('orchestra:unauthorized', unauthorized)
|
||||
api.overview()
|
||||
.then(() => setReady(true))
|
||||
.catch(() => setReady(false))
|
||||
api.session()
|
||||
.then((session) => setAccount(session))
|
||||
.catch(() => setAccount(undefined))
|
||||
.finally(() => setChecking(false))
|
||||
return () => window.removeEventListener('orchestra:unauthorized', unauthorized)
|
||||
}, [])
|
||||
@@ -1858,7 +1879,12 @@ function App() {
|
||||
await api.logout()
|
||||
client.clear()
|
||||
setMessage('You have signed out.')
|
||||
setReady(false)
|
||||
setAccount(undefined)
|
||||
}
|
||||
const credentialsChanged = (username: string) => {
|
||||
client.clear()
|
||||
setMessage(`Credentials for ${username} were saved. Sign in again to continue.`)
|
||||
setAccount(undefined)
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
@@ -1871,9 +1897,9 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
return ready
|
||||
? <RoutesApp onLogout={logout} />
|
||||
: <Login message={message} onAuthenticated={() => { client.clear(); setMessage(''); setReady(true) }} />
|
||||
return account
|
||||
? <RoutesApp account={account} onLogout={logout} onCredentialsChanged={credentialsChanged} />
|
||||
: <Login message={message} onAuthenticated={(authenticated) => { client.clear(); setMessage(''); setAccount(authenticated) }} />
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
+559
-9
@@ -25,6 +25,8 @@
|
||||
--amber-wash: rgba(229, 164, 76, 0.12);
|
||||
--red: #ec8f85;
|
||||
--red-wash: rgba(226, 105, 92, 0.12);
|
||||
--violet: #b5a2ef;
|
||||
--violet-wash: rgba(154, 126, 226, 0.12);
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
--radius-sm: 8px;
|
||||
--radius: 12px;
|
||||
@@ -520,10 +522,49 @@ time {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.account-menu > span {
|
||||
padding: 3px 4px;
|
||||
.account-menu-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 3px 4px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.account-menu-user > span:last-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.account-menu-user b {
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.account-menu-user small {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.account-menu > a {
|
||||
display: flex;
|
||||
min-height: 33px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-soft);
|
||||
background: var(--surface-3);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.account-menu > a:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.account-menu button {
|
||||
@@ -966,6 +1007,10 @@ time {
|
||||
grid-template-columns: repeat(5, minmax(235px, 1fr));
|
||||
}
|
||||
|
||||
.task-board.lanes-7 {
|
||||
grid-template-columns: repeat(7, minmax(225px, 1fr));
|
||||
}
|
||||
|
||||
.task-lane {
|
||||
min-height: 310px;
|
||||
padding: 10px;
|
||||
@@ -1016,11 +1061,23 @@ time {
|
||||
}
|
||||
|
||||
.lane-blocked > header i,
|
||||
.lane-needs_attention > header i,
|
||||
.status-pill.state-blocked i {
|
||||
background: var(--amber);
|
||||
box-shadow: 0 0 0 3px var(--amber-wash);
|
||||
}
|
||||
|
||||
.status-pill.state-needs_attention i {
|
||||
background: var(--amber);
|
||||
box-shadow: 0 0 0 3px var(--amber-wash);
|
||||
}
|
||||
|
||||
.lane-in_review > header i,
|
||||
.status-pill.state-in_review i {
|
||||
background: var(--violet);
|
||||
box-shadow: 0 0 0 3px var(--violet-wash);
|
||||
}
|
||||
|
||||
.lane-completed > header i,
|
||||
.status-pill.state-completed i {
|
||||
background: #8fcfc5;
|
||||
@@ -1085,6 +1142,15 @@ time {
|
||||
border-color: rgba(239, 189, 114, 0.18);
|
||||
}
|
||||
|
||||
.task-card.state-needs_attention {
|
||||
border-color: rgba(239, 189, 114, 0.25);
|
||||
background: linear-gradient(145deg, rgba(62, 46, 27, 0.28), var(--surface-2));
|
||||
}
|
||||
|
||||
.task-card.state-in_review {
|
||||
border-color: rgba(181, 162, 239, 0.2);
|
||||
}
|
||||
|
||||
.task-card.state-failed {
|
||||
border-color: rgba(236, 143, 133, 0.18);
|
||||
}
|
||||
@@ -1122,6 +1188,14 @@ time {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.status-pill.state-needs_attention {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.status-pill.state-in_review {
|
||||
color: var(--violet);
|
||||
}
|
||||
|
||||
.status-pill.state-failed {
|
||||
color: var(--red);
|
||||
}
|
||||
@@ -1545,11 +1619,17 @@ time {
|
||||
}
|
||||
|
||||
.diagnosis.state-blocked,
|
||||
.diagnosis.state-needs_attention,
|
||||
.diagnosis.state-failed {
|
||||
border-color: rgba(239, 189, 114, 0.23);
|
||||
background: linear-gradient(110deg, var(--amber-wash), rgba(17, 23, 27, 0.65));
|
||||
}
|
||||
|
||||
.diagnosis.state-in_review {
|
||||
border-color: rgba(181, 162, 239, 0.26);
|
||||
background: linear-gradient(110deg, var(--violet-wash), rgba(17, 23, 27, 0.65));
|
||||
}
|
||||
|
||||
.diagnosis-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -1570,12 +1650,19 @@ time {
|
||||
}
|
||||
|
||||
.diagnosis-symbol.state-blocked,
|
||||
.diagnosis-symbol.state-needs_attention,
|
||||
.diagnosis-symbol.state-failed {
|
||||
border-color: rgba(239, 189, 114, 0.25);
|
||||
color: var(--amber);
|
||||
background: var(--amber-wash);
|
||||
}
|
||||
|
||||
.diagnosis-symbol.state-in_review {
|
||||
border-color: rgba(181, 162, 239, 0.3);
|
||||
color: var(--violet);
|
||||
background: var(--violet-wash);
|
||||
}
|
||||
|
||||
.diagnosis h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
@@ -2492,6 +2579,259 @@ time {
|
||||
font: 9px var(--mono);
|
||||
}
|
||||
|
||||
/* Account settings */
|
||||
|
||||
.settings-page {
|
||||
max-width: 1160px;
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-card,
|
||||
.settings-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(145deg, rgba(21, 29, 33, 0.94), rgba(14, 20, 23, 0.94));
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--accent-line);
|
||||
border-radius: 17px;
|
||||
color: var(--accent-strong);
|
||||
background: linear-gradient(145deg, rgba(86, 200, 139, 0.2), var(--accent-wash));
|
||||
box-shadow: inset 0 1px rgba(255, 255, 255, 0.05), 0 12px 30px rgba(0, 0, 0, 0.18);
|
||||
font: 600 16px var(--mono);
|
||||
}
|
||||
|
||||
.profile-card h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.035em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.profile-card > p {
|
||||
margin: 5px 0 22px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.profile-card dl {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 17px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.profile-card dl > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.profile-card dt {
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.profile-card dd {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
font: 9px var(--mono);
|
||||
}
|
||||
|
||||
.database-badge {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--accent-line);
|
||||
border-radius: 10px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.database-badge > span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.database-badge b {
|
||||
color: var(--accent-strong);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.database-badge small {
|
||||
color: var(--text-soft);
|
||||
font-size: 9px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-card > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 13px;
|
||||
padding: 22px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(17, 23, 27, 0.55);
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
border: 1px solid var(--accent-line);
|
||||
border-radius: 11px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.settings-card h2 {
|
||||
margin: 1px 0 0;
|
||||
font-size: 16px;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.settings-card header p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.settings-card form {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.settings-card label,
|
||||
.current-password-block > label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: var(--text-soft);
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.settings-field {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.settings-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 14px 0 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings-divider::after {
|
||||
height: 1px;
|
||||
flex: 1;
|
||||
background: var(--line);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.password-rules {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.password-rules span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-1);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.password-rules span.met {
|
||||
border-color: var(--accent-line);
|
||||
color: var(--accent);
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.current-password-block {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 0.65fr);
|
||||
align-items: end;
|
||||
gap: 5px 18px;
|
||||
margin-top: 14px;
|
||||
padding: 15px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.current-password-block label {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.current-password-block p {
|
||||
grid-column: 1;
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.current-password-block input {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin: 10px -24px -24px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(10, 15, 17, 0.28);
|
||||
}
|
||||
|
||||
.settings-actions > span {
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* Artifact and login */
|
||||
|
||||
.artifact-header {
|
||||
@@ -2513,7 +2853,8 @@ time {
|
||||
overflow: hidden;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at 50% -10%, rgba(86, 200, 139, 0.15), transparent 36rem),
|
||||
radial-gradient(circle at 24% 10%, rgba(86, 200, 139, 0.17), transparent 34rem),
|
||||
radial-gradient(circle at 88% 92%, rgba(117, 96, 186, 0.09), transparent 30rem),
|
||||
#090d0f;
|
||||
}
|
||||
|
||||
@@ -2528,12 +2869,158 @@ time {
|
||||
mask-image: radial-gradient(circle at center, black, transparent 68%);
|
||||
}
|
||||
|
||||
.login-layout {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: min(1040px, 100%);
|
||||
grid-template-columns: minmax(0, 1fr) 420px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.login-showcase {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 610px;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 36px 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(25, 38, 36, 0.9), rgba(12, 18, 21, 0.94)),
|
||||
var(--surface-1);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.login-showcase::before {
|
||||
position: absolute;
|
||||
width: 430px;
|
||||
height: 430px;
|
||||
right: -190px;
|
||||
top: -170px;
|
||||
border: 1px solid rgba(142, 224, 178, 0.08);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 0 55px rgba(142, 224, 178, 0.025),
|
||||
0 0 0 110px rgba(142, 224, 178, 0.018);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.showcase-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.showcase-brand > span:last-child {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.showcase-brand b {
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.showcase-brand small {
|
||||
color: var(--text-muted);
|
||||
font: 8px var(--mono);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.showcase-copy {
|
||||
position: relative;
|
||||
max-width: 500px;
|
||||
margin: auto 0 42px;
|
||||
}
|
||||
|
||||
.showcase-copy h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(38px, 4.2vw, 58px);
|
||||
font-weight: 620;
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.064em;
|
||||
}
|
||||
|
||||
.showcase-copy p {
|
||||
max-width: 460px;
|
||||
margin: 20px 0 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.showcase-flow {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(16px, 1fr) auto minmax(16px, 1fr) auto minmax(16px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 36px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(8, 13, 15, 0.34);
|
||||
}
|
||||
|
||||
.showcase-flow > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-soft);
|
||||
font: 8px var(--mono);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.showcase-flow > b {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--line-strong), var(--accent-line));
|
||||
}
|
||||
|
||||
.flow-dot {
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--blue);
|
||||
box-shadow: 0 0 0 3px var(--blue-wash);
|
||||
}
|
||||
|
||||
.flow-dot.active,
|
||||
.flow-dot.done {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-wash);
|
||||
}
|
||||
|
||||
.flow-dot.review {
|
||||
background: var(--violet);
|
||||
box-shadow: 0 0 0 3px var(--violet-wash);
|
||||
}
|
||||
|
||||
.login-showcase > footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--text-muted);
|
||||
font: 8px var(--mono);
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-showcase > footer .signal {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
width: min(430px, 100%);
|
||||
padding: 27px;
|
||||
width: 100%;
|
||||
align-self: center;
|
||||
padding: 30px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 20px;
|
||||
border-radius: 22px;
|
||||
background: rgba(17, 24, 28, 0.95);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
@@ -2728,6 +3215,14 @@ time {
|
||||
.detail-layout {
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
}
|
||||
|
||||
.login-layout {
|
||||
grid-template-columns: minmax(0, 1fr) 390px;
|
||||
}
|
||||
|
||||
.login-showcase {
|
||||
padding: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
@@ -2765,6 +3260,39 @@ time {
|
||||
.search-field {
|
||||
width: calc(100% - 164px);
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
grid-row: span 2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-card > p {
|
||||
margin: 3px 0 16px;
|
||||
}
|
||||
|
||||
.profile-card dl,
|
||||
.database-badge {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.login-layout {
|
||||
width: min(430px, 100%);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.login-showcase {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
@@ -2795,7 +3323,8 @@ time {
|
||||
}
|
||||
|
||||
.primary-nav a {
|
||||
width: min(150px, 42vw);
|
||||
width: auto;
|
||||
flex: 1;
|
||||
grid-template-columns: auto auto;
|
||||
place-content: center;
|
||||
gap: 7px;
|
||||
@@ -2886,13 +3415,15 @@ time {
|
||||
|
||||
.task-board.lanes-2,
|
||||
.task-board.lanes-3,
|
||||
.task-board.lanes-5 {
|
||||
.task-board.lanes-5,
|
||||
.task-board.lanes-7 {
|
||||
grid-template-columns: repeat(var(--mobile-lanes, 5), minmax(255px, 82vw));
|
||||
}
|
||||
|
||||
.task-board.lanes-2 { --mobile-lanes: 2; }
|
||||
.task-board.lanes-3 { --mobile-lanes: 3; }
|
||||
.task-board.lanes-5 { --mobile-lanes: 5; }
|
||||
.task-board.lanes-7 { --mobile-lanes: 7; }
|
||||
|
||||
.task-lane {
|
||||
min-height: 270px;
|
||||
@@ -2974,6 +3505,25 @@ time {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.current-password-block {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.current-password-block input {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user