Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/web/app/layouts/default.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
<template>
<div class="min-h-screen bg-canvas text-ink">
<div class="min-h-dvh bg-canvas text-ink">
<AppSidebar />
<main class="min-h-screen transition-[margin]" :class="collapsed ? 'md:ms-16' : 'md:ms-52'">
<!-- Inert while the narrow-screen drawer is open, so focus and screen readers stay in it. -->
<main
class="min-h-dvh flex flex-col transition-[margin]"
:class="collapsed ? 'md:ms-16' : 'md:ms-52'"
:inert="open"
>
<slot />
</main>
<AppShortcutsDialog />
Expand All @@ -10,6 +15,7 @@

<script setup lang="ts">
const collapsed = useSidebarCollapsed();
const open = useSidebarOpen();

// Bound here rather than in a plugin so the shell owns them: the sign-in pages use the `auth`
// layout and should not answer to `g a`. The bindings resolve `document` lazily, so SSR is safe.
Expand Down
39 changes: 12 additions & 27 deletions apps/web/app/pages/(dashboard)/audit.vue
Original file line number Diff line number Diff line change
@@ -1,32 +1,17 @@
<template>
<header
class="h-16 flex items-center justify-between border-b border-line bg-canvas/92 px-4 backdrop-blur md:px-6"
>
<div>
<p class="m-0 text-3xs text-muted font-700 tracking-[0.18em] uppercase">
{{ $t('dashboard.audit.header.eyebrow') }}
</p>
<h1 class="m-0 mt-1 text-lg font-650 tracking-tight">
{{ $t('dashboard.audit.header.title') }}
</h1>
</div>

<div class="flex items-center gap-3">
<LocaleSwitcher />
<ClientOnly>
<ColorModeToggle />
<template #fallback>
<span class="h-9 w-9 border border-line bg-raised" aria-hidden="true" />
</template>
</ClientOnly>
<button class="btn-subtle gap-2 px-3" type="button" :disabled="pending" @click="refresh">
<Icon aria-hidden="true" class="h-3.5 w-3.5" name="lucide:refresh-cw" />
{{ $t('common.actions.refresh') }}
</button>
</div>
</header>
<AppNavbar :title="$t('dashboard.audit.header.title')">
<button
class="btn-subtle gap-2 px-3 max-sm:h-11 max-sm:w-11 max-sm:px-0"
type="button"
:disabled="pending"
@click="refresh"
>
<Icon aria-hidden="true" class="h-3.5 w-3.5 max-sm:h-4 max-sm:w-4" name="lucide:refresh-cw" />
<span class="max-sm:sr-only">{{ $t('common.actions.refresh') }}</span>
</button>
</AppNavbar>

<div class="p-3 sm:p-4 md:p-5">
<div class="p-4 sm:p-6">
<AuditTable ref="auditTable" :rows="rows" :pending="pending" :error="error" @retry="refresh" />

<!-- Reported beside the list, never in place of it: the control plane's own records are this
Expand Down
134 changes: 68 additions & 66 deletions apps/web/app/pages/(dashboard)/index.vue
Original file line number Diff line number Diff line change
@@ -1,72 +1,56 @@
<template>
<header
class="h-16 flex items-center justify-between border-b border-line bg-canvas/92 px-4 backdrop-blur md:px-6"
>
<div>
<p class="m-0 text-3xs text-muted font-700 tracking-[0.18em] uppercase">
{{ $t('dashboard.header.eyebrow') }}
</p>
<h1 class="m-0 mt-1 text-lg font-650 tracking-tight">{{ $t('dashboard.header.title') }}</h1>
</div>

<div class="flex items-center gap-3">
<div class="hidden h-9 items-center gap-2 border border-line bg-raised px-3 lg:flex">
<span class="label-upper">{{ $t('dashboard.header.mode') }}</span>
</div>
<!--
Says whether the board is following the control plane right now. Without it a stalled
stream is indistinguishable from a quiet one, and a quiet board is exactly what an
operator would take as "nothing is happening".
-->
<ClientOnly>
<div
class="hidden h-9 items-center gap-2 border border-line bg-raised px-3 sm:flex"
:aria-label="live ? $t('dashboard.header.liveAria') : $t('dashboard.header.staleAria')"
role="status"
>
<span
aria-hidden="true"
class="h-1.5 w-1.5 rounded-full"
:class="live ? 'bg-accent' : 'bg-muted'"
/>
<span class="label-upper">
{{ live ? $t('dashboard.header.live') : $t('dashboard.header.stale') }}
</span>
</div>
</ClientOnly>
<ClientOnly>
<div class="hidden h-9 items-center gap-2 border border-line bg-raised px-3 sm:flex">
<Icon aria-hidden="true" class="h-3.5 w-3.5 text-muted" name="lucide:clock-3" />
<span class="mono text-ink">{{ now.toISOString().slice(0, 19) }}Z</span>
</div>
</ClientOnly>
<LocaleSwitcher />
<ClientOnly>
<ColorModeToggle />
<template #fallback>
<span class="h-9 w-9 border border-line bg-raised" aria-hidden="true" />
</template>
</ClientOnly>
<button class="btn-subtle gap-2 px-3" type="button" @click="refreshDashboard">
<Icon aria-hidden="true" class="h-3.5 w-3.5" name="lucide:refresh-cw" />
{{ $t('common.actions.refresh') }}
</button>
<AppNavbar :title="$t('dashboard.header.title')">
<!--
Says whether the board is following the control plane right now. Without it a stalled
stream is indistinguishable from a quiet one, and a quiet board is exactly what an
operator would take as "nothing is happening". A dot alone on a phone, labelled from `sm`.
-->
<ClientOnly>
<div
aria-hidden="true"
class="h-9 w-9 hidden place-items-center border border-accent/45 bg-accent/8 font-mono text-xs text-accent font-700 sm:grid"
class="h-9 flex items-center gap-2 sm:border sm:border-line sm:bg-raised sm:px-3"
:aria-label="live ? $t('dashboard.header.liveAria') : $t('dashboard.header.staleAria')"
role="status"
>
AZ
<span
aria-hidden="true"
class="h-2 w-2 rounded-full sm:h-1.5 sm:w-1.5"
:class="live ? 'bg-accent' : 'bg-muted'"
/>
<span class="label-upper max-sm:hidden">
{{ live ? $t('dashboard.header.live') : $t('dashboard.header.stale') }}
</span>
</div>
</div>
</header>
</ClientOnly>
<ClientOnly>
<div class="hidden h-9 items-center gap-2 border border-line bg-raised px-3 xl:flex">
<Icon aria-hidden="true" class="h-3.5 w-3.5 text-muted" name="lucide:clock-3" />
<span class="mono text-ink">{{ now.toISOString().slice(0, 19) }}Z</span>
</div>
</ClientOnly>
<button
class="btn-subtle gap-2 px-3 max-sm:h-11 max-sm:w-11 max-sm:px-0"
type="button"
@click="refreshDashboard"
>
<Icon aria-hidden="true" class="h-3.5 w-3.5 max-sm:h-4 max-sm:w-4" name="lucide:refresh-cw" />
<span class="max-sm:sr-only">{{ $t('common.actions.refresh') }}</span>
</button>
<button
class="btn-primary gap-2 px-3 max-sm:h-11 max-sm:w-11 max-sm:px-0"
type="button"
aria-haspopup="dialog"
@click="newTaskOpen = true"
>
<Icon aria-hidden="true" class="h-3.5 w-3.5 max-sm:h-4 max-sm:w-4" name="lucide:plus" />
<span class="max-sm:sr-only">{{ $t('dashboard.newTask.title') }}</span>
</button>
</AppNavbar>

<div class="p-3 sm:p-4 md:p-5">
<div class="flex flex-col gap-4 p-4 sm:gap-6 sm:p-6">
<RunnerMetrics :overview="overview" />

<NewTaskForm class="mt-4" :pending="createPending" :error="createError" @submit="createTask" />

<section class="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]">
<div class="min-w-0 space-y-4">
<section class="grid gap-4 sm:gap-6 xl:grid-cols-[minmax(0,1fr)_22rem]">
<div class="min-w-0 flex flex-col gap-4 sm:gap-6">
<TaskTable
ref="taskTable"
:tasks="overview.tasks"
Expand All @@ -84,6 +68,13 @@
/>
</section>
</div>

<NewTaskDialog
v-model:open="newTaskOpen"
:pending="createPending"
:error="createError"
@submit="createTask"
/>
</template>

<script setup lang="ts">
Expand Down Expand Up @@ -170,6 +161,12 @@ const decisionPending = ref(false);
const decisionError = ref<string>();
const createPending = ref(false);
const createError = ref<string>();
const newTaskOpen = ref(false);

// A failure belongs to the attempt that produced it; reopening starts without it.
watch(newTaskOpen, (isOpen) => {
if (isOpen) createError.value = undefined;
});

/**
* Queues a task through the control plane.
Expand All @@ -184,6 +181,7 @@ async function createTask(request: NewTaskRequest): Promise<void> {
createError.value = undefined;
try {
await $orpc.tasks.create(request);
newTaskOpen.value = false;
} catch {
// Refusals name a rule, not a value, but the server's text is still untrusted input this page
// would render; the trail records which rule refused.
Expand Down Expand Up @@ -253,16 +251,20 @@ function moveSelection(offset: number): void {
selectedId.value = ids[next];
}

// Stands down while the new-task dialog is open, so keys meant for the form leave the board alone.
const modalOpen = useModalDialogOpen();
const notWhileModalOpen = { enabled: () => !modalOpen.value };

// The sheet under `?` lists these; keep the two in step when either changes.
useHotkeys([
{ hotkey: 'J', callback: () => moveSelection(1) },
{ hotkey: 'K', callback: () => moveSelection(-1) },
{ hotkey: 'R', callback: refreshDashboard },
{ hotkey: 'J', callback: () => moveSelection(1), options: notWhileModalOpen },
{ hotkey: 'K', callback: () => moveSelection(-1), options: notWhileModalOpen },
{ hotkey: 'R', callback: refreshDashboard, options: notWhileModalOpen },
{
hotkey: '/',
callback: () => taskTable.value?.focusFilter(),
// Otherwise the slash lands in the field it just focused.
options: { preventDefault: true },
options: { ...notWhileModalOpen, preventDefault: true },
},
]);
</script>
45 changes: 12 additions & 33 deletions apps/web/app/pages/(dashboard)/settings.vue
Original file line number Diff line number Diff line change
@@ -1,38 +1,17 @@
<template>
<header
class="h-16 flex items-center justify-between border-b border-line bg-canvas/92 px-4 backdrop-blur md:px-6"
>
<div>
<p class="m-0 text-3xs text-muted font-700 tracking-[0.18em] uppercase">
{{ $t('dashboard.settings.header.eyebrow') }}
</p>
<h1 class="m-0 mt-1 text-lg font-650 tracking-tight">
{{ $t('dashboard.settings.header.title') }}
</h1>
</div>

<div class="flex items-center gap-3">
<LocaleSwitcher />
<ClientOnly>
<ColorModeToggle />
<template #fallback>
<span class="h-9 w-9 border border-line bg-raised" aria-hidden="true" />
</template>
</ClientOnly>
<button
class="btn-subtle gap-2 px-3"
type="button"
:disabled="pending"
:aria-label="$t('common.actions.refresh')"
@click="refresh"
>
<Icon aria-hidden="true" class="h-3.5 w-3.5" name="lucide:refresh-cw" />
<span class="hidden sm:inline">{{ $t('common.actions.refresh') }}</span>
</button>
</div>
</header>
<AppNavbar :title="$t('dashboard.settings.header.title')">
<button
class="btn-subtle gap-2 px-3 max-sm:h-11 max-sm:w-11 max-sm:px-0"
type="button"
:disabled="pending"
@click="refresh"
>
<Icon aria-hidden="true" class="h-3.5 w-3.5 max-sm:h-4 max-sm:w-4" name="lucide:refresh-cw" />
<span class="max-sm:sr-only">{{ $t('common.actions.refresh') }}</span>
</button>
</AppNavbar>

<div class="p-3 sm:p-4 md:p-5">
<div class="p-4 sm:p-6">
<SettingsState
v-if="!view && (pending || !error)"
kind="loading"
Expand Down
9 changes: 4 additions & 5 deletions apps/web/app/pages/(organizations)/organizations/index.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
<template>
<div class="mx-auto max-w-4xl p-6">
<header>
<h1 class="m-0 text-lg text-ink font-650">{{ $t('organizations.title') }}</h1>
<p class="m-0 mt-1 text-xs text-muted">{{ $t('organizations.subtitle') }}</p>
</header>
<AppNavbar :title="$t('organizations.title')" />

<div class="mx-auto max-w-4xl w-full p-4 sm:p-6">
<p class="m-0 text-xs text-muted">{{ $t('organizations.subtitle') }}</p>

<p v-if="error" class="m-0 mt-4 border border-line bg-raised p-3 text-xs text-ink" role="alert">
{{ $t(error) }}
Expand Down
42 changes: 33 additions & 9 deletions apps/web/modules/auth/components/UserMenu.vue
Original file line number Diff line number Diff line change
@@ -1,21 +1,45 @@
<template>
<div v-if="ready && loggedIn" class="border-t border-line p-4">
<p class="m-0 label-upper">
{{ $t('auth.session.signedInAs') }}
</p>
<p class="m-0 mt-1 truncate text-xs font-650" :title="user?.email">
{{ user?.name || user?.email }}
</p>
<div v-if="ready && loggedIn" class="flex items-center gap-2.5 border-t border-line p-3">
<span
aria-hidden="true"
class="h-8 w-8 shrink-0 grid place-items-center rounded-full border border-line bg-raised font-mono text-3xs text-ink font-700 uppercase"
>
{{ initials }}
</span>
<div class="min-w-0 flex-1">
<span class="sr-only">{{ $t('auth.session.signedInAs') }}</span>
<p class="m-0 truncate text-xs font-650" :title="user?.email">
{{ user?.name || user?.email }}
</p>
<p v-if="user?.name" class="m-0 truncate text-3xs text-muted">{{ user.email }}</p>
</div>
<button
class="focus-ring mt-2.5 h-8 w-full border border-line bg-raised text-xs text-ink font-650 transition hover:border-muted"
class="btn-icon shrink-0 border-transparent bg-transparent text-muted hover:text-ink max-md:h-11 max-md:w-11"
type="button"
:aria-label="$t('auth.session.signOut')"
:title="$t('auth.session.signOut')"
@click="signOut()"
>
{{ $t('auth.session.signOut') }}
<Icon aria-hidden="true" class="h-4 w-4 rtl:scale-x-[-1]" name="lucide:log-out" />
</button>
</div>
</template>

<script setup lang="ts">
const WHITESPACE = /\s+/;

const { user, loggedIn, ready, signOut } = useUserSession();

/** Up to two letters for the avatar: the name's first two words, or the email's first letter. */
const initials = computed(() => {
const name = user.value?.name?.trim();
if (name) {
return name
.split(WHITESPACE)
.slice(0, 2)
.map((word) => word.charAt(0))
.join('');
}
return user.value?.email?.charAt(0) ?? '';
});
</script>
Loading
Loading