Skip to content
Merged
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,28 @@ Rules:
- Status pills and tinted panels: `var(--color-{info,success,warning,danger,primary}-50)` background with the matching `-700` text. These flip per theme; the pale `#eff6ff`/`#fef3c7`/`#fee2e2` hexes do not.
- Solid saturated fills (a colored button background, a status dot, an icon) may stay as a saturated color; those read on both themes. Do not convert those to the `-50` tints.
- Inputs/selects must set both `background` and `color` (a missing `color` shows black text on a dark surface). Prefer `BaseInput`/`BaseSelect`, which handle this.
- Native form controls are not accepted when a base primitive supports the same control. Theme correctness belongs to the primitive, not each page.
- Every changed form must be exercised with `data-theme="light"` and `data-theme="dark"`. Check entered text, placeholders, disabled controls, browser autofill, dropdown options, focus rings, and modal surfaces.

## Authorization: permission and resource scope are separate

A module permission answers what kind of operation an actor may perform. A resource grant answers where they may perform it. Features that operate on deployments, buckets, peers, or another owned resource must enforce both.

Rules:

- Define dedicated read and write permissions for each module. Do not reuse an unrelated permission because the feature shares a page or transport.
- Enforce permissions and resource scope in the agent API. Router guards and hidden buttons improve the experience but are not security boundaries.
- Filter collection responses to resources the actor may read. Validate every resource referenced by create, update, delete, bulk, and action requests.
- A bulk update must preserve records outside the actor's scope. Never let a scoped request replace a global collection.
- Host-wide, fleet-wide, and all-resource operations require an explicit global permission. An empty resource identifier must not silently mean global access.
- API keys may narrow their user's grants. Use the intersection of user and key access.
- The UI must hide mutation controls without write permission and exclude read-only resources from target selectors.
- Tests must call the HTTP endpoint with two actors whose resource grants differ. Prove that each actor sees only allowed records and cannot change the other actor's records.

## Review checklist (UI changes)

- [ ] New inputs/selects/buttons/cards reuse `src/components/base/` rather than bespoke markup. Flag any hand-rolled control that duplicates a primitive.
- [ ] No hardcoded hex/`white`/`black` in `<style>` or inline `style=` for surfaces, text, borders, or status tints. Semantic tokens used instead.
- [ ] Rendered in both light and dark (toggle in the sidebar). Inputs, dropdowns, modals, and tables are readable in both.
- [ ] Module permissions and resource grants are enforced by the API, reflected by the route, and represented by the controls.
- [ ] `type-check`, `lint`, `format:check`, and `test:run` pass.
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
# Changelog

## [0.4.0-beta.5] - 2026-08-22
## [0.4.0-beta.6] - 2026-08-23

### Added
- Guided Fleet setup, peer access controls, runtime provider selection, and remote deployment inventories
- Deployment autoscaling configuration with workload compatibility and activation guidance
- Grouped notification incidents with editable targets and delivery rules
- HTTP, TCP, and container command health checks for web services and databases

### Changed
- Deployments show the selected server in the navigation and remain local by default
- Deployment configuration keeps scaling beside settings while service image changes remain in the overview

### Fixed
- Assigned local deployments load without Fleet access or Fleet availability
- Peer deployment lists reload when switching from an empty local server
- Peer deployment details open for users with read access
- Object storage and notifications follow their dedicated permissions
- Operators no longer see Updates unless access is explicitly granted
- Read-only Settings access is clearly identified and its controls remain legible in dark mode

## [0.4.0-beta.4] - 2026-08-21

### Added
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@flatrun/ui",
"version": "0.4.0-beta.5",
"version": "0.4.0-beta.6",
"description": "Web interface for FlatRun container orchestration",
"author": "FlatRun",
"license": "MIT",
Expand Down
14 changes: 10 additions & 4 deletions src/components/AlertRulesPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { mount, flushPromises } from "@vue/test-utils";
import { createTestingPinia } from "@pinia/testing";
import AlertRulesPanel from "./AlertRulesPanel.vue";
import { observabilityApi } from "@/services/observability";
import { useAuthStore } from "@/stores/auth";

vi.mock("@/services/observability", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/services/observability")>();
Expand All @@ -13,7 +14,7 @@ vi.mock("@/services/observability", async (importOriginal) => {
});

vi.mock("@/services/api", () => ({
notificationsApi: { getTargets: vi.fn().mockResolvedValue({ data: { targets: [] } }) },
notificationsApi: { getAlertTargetOptions: vi.fn().mockResolvedValue({ data: { targets: [] } }) },
}));

const rule = {
Expand All @@ -27,16 +28,21 @@ const rule = {
enabled: true,
};

const mountPanel = () =>
mount(AlertRulesPanel, {
const mountPanel = () => {
const pinia = createTestingPinia({ createSpy: vi.fn });
const auth = useAuthStore(pinia);
vi.mocked(auth.hasPermission).mockReturnValue(true);
vi.mocked(auth.canAccessDeployment).mockReturnValue(true);
return mount(AlertRulesPanel, {
props: { deployments: ["shop", "blog"] },
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
plugins: [pinia],
stubs: {
BaseModal: { template: "<div v-if='visible'><slot /><slot name='footer' /></div>", props: ["visible"] },
},
},
});
};

describe("AlertRulesPanel", () => {
beforeEach(() => {
Expand Down
36 changes: 22 additions & 14 deletions src/components/AlertRulesPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<section class="arp">
<header class="arp-head">
<h3><Icon name="bell" :size="16" /> Alert rules</h3>
<button class="btn btn-sm btn-primary" @click="openNew">
<button v-if="canCreate" class="btn btn-sm btn-primary" @click="openNew">
<Icon name="plus" :size="14" />
New rule
</button>
Expand All @@ -26,7 +26,7 @@
<span v-if="firingIds.has(rule.id ?? '')" class="arp-firing">Firing</span>
<span v-else-if="!rule.enabled" class="arp-off">Off</span>

<div class="arp-rule-actions">
<div v-if="canEditRule(rule)" class="arp-rule-actions">
<button class="btn btn-sm btn-icon" title="Edit rule" @click="openEdit(rule)">
<Icon name="pencil" :size="14" />
</button>
Expand All @@ -45,11 +45,11 @@

<BaseField
label="Deployment"
:hint="isHostMetric ? 'A host metric watches the whole machine.' : 'Leave empty to watch every deployment.'"
:hint="isHostMetric ? 'A host metric watches the whole machine.' : 'Choose the deployment this rule watches.'"
>
<BaseSelect v-model="draft.deployment" :disabled="isHostMetric">
<option value="">{{ isHostMetric ? "Whole host" : "Every deployment" }}</option>
<option v-for="d in deployments" :key="d" :value="d">{{ d }}</option>
<option v-if="auth.isAdmin" value="">{{ isHostMetric ? "Whole host" : "Every deployment" }}</option>
<option v-for="d in writableDeployments" :key="d" :value="d">{{ d }}</option>
</BaseSelect>
</BaseField>

Expand Down Expand Up @@ -86,7 +86,7 @@
{{ t.name }}
</label>
</div>
<p v-else class="arp-muted">No notification targets yet. Add one in Settings to send anywhere.</p>
<p v-else class="arp-muted">No enabled notification targets are available. An administrator can add one.</p>
</BaseField>

<BaseField
Expand Down Expand Up @@ -124,15 +124,22 @@ import { observabilityApi, METRIC } from "@/services/observability";
import type { AlertRule, AlertEvent } from "@/services/observability";
import { notificationsApi, type NotificationTarget } from "@/services/api";
import { useNotificationsStore } from "@/stores/notifications";
import { useAuthStore } from "@/stores/auth";
import Icon from "@/components/base/Icon.vue";
import BaseModal from "@/components/base/BaseModal.vue";
import BaseField from "@/components/base/BaseField.vue";
import BaseInput from "@/components/base/BaseInput.vue";
import BaseSelect from "@/components/base/BaseSelect.vue";

defineProps<{ deployments: string[] }>();
const props = defineProps<{ deployments: string[] }>();

const notifications = useNotificationsStore();
const auth = useAuthStore();
const canWrite = computed(() => auth.hasPermission("alerts:write"));
const writableDeployments = computed(() => props.deployments.filter((name) => auth.canAccessDeployment(name, "write")));
const canCreate = computed(() => canWrite.value && (auth.isAdmin || writableDeployments.value.length > 0));
const canEditRule = (rule: AlertRule) =>
canWrite.value && (auth.isAdmin || (!!rule.deployment && auth.canAccessDeployment(rule.deployment, "write")));

const rules = ref<AlertRule[]>([]);
const firing = ref<AlertEvent[]>([]);
Expand All @@ -157,7 +164,7 @@ const firingSnapshot = (id?: string): string => {
return ev.snapshot.map((c) => `${c.container} (${asBytes ? bytes(c.value) : `${c.value.toFixed(1)}%`})`).join(", ");
};

const metricOptions: { value: string; label: string; unit: string; host?: boolean; rate?: boolean }[] = [
const allMetricOptions: { value: string; label: string; unit: string; host?: boolean; rate?: boolean }[] = [
{ value: METRIC.cpu, label: "Container CPU usage", unit: "percent" },
{ value: METRIC.memUsage, label: "Container memory usage", unit: "bytes" },
{ value: METRIC.netRx, label: "Container network in (per second)", unit: "bytes", rate: true },
Expand All @@ -167,12 +174,13 @@ const metricOptions: { value: string; label: string; unit: string; host?: boolea
{ value: METRIC.hostMemUsage, label: "Host memory used", unit: "bytes", host: true },
{ value: METRIC.hostDisk, label: "Host disk used %", unit: "percent", host: true },
];
const metricOptions = computed(() => allMetricOptions.filter((metric) => auth.isAdmin || !metric.host));

const notifyTargets = ref<NotificationTarget[]>([]);
const notifyTargets = ref<Pick<NotificationTarget, "id" | "name">[]>([]);

const blank = (): AlertRule => ({
name: "",
deployment: "",
deployment: auth.isAdmin ? "" : (writableDeployments.value[0] ?? ""),
metric: METRIC.cpu,
comparison: "above",
threshold: 80,
Expand All @@ -194,7 +202,7 @@ const toggleTarget = (id: string) => {
// A host metric is machine-wide, so it is never scoped to a deployment; picking
// one clears any deployment so the rule reads the host series. It also has no
// deployment to restart, so the action is cleared too.
const isHostMetric = computed(() => metricOptions.find((m) => m.value === draft.value.metric)?.host === true);
const isHostMetric = computed(() => metricOptions.value.find((m) => m.value === draft.value.metric)?.host === true);
watch(isHostMetric, (host) => {
if (host) {
draft.value.deployment = "";
Expand All @@ -203,13 +211,13 @@ watch(isHostMetric, (host) => {
});

const unitHint = computed(() => {
const opt = metricOptions.find((m) => m.value === draft.value.metric);
const opt = metricOptions.value.find((m) => m.value === draft.value.metric);
if (opt?.unit !== "bytes") return "A percentage.";
return opt.rate ? "In bytes per second." : "In bytes.";
});

const describe = (rule: AlertRule) => {
const opt = metricOptions.find((m) => m.value === rule.metric);
const opt = metricOptions.value.find((m) => m.value === rule.metric);
const metric = opt?.label ?? rule.metric;
const value = opt?.unit === "bytes" ? bytes(rule.threshold) + (opt.rate ? "/s" : "") : `${rule.threshold}%`;
const where = rule.deployment ? rule.deployment : "any deployment";
Expand All @@ -231,7 +239,7 @@ const load = async () => {
const [rulesResult, firingResult, targetsResult] = await Promise.allSettled([
observabilityApi.alertRules(),
observabilityApi.firingAlerts(),
notificationsApi.getTargets(),
notificationsApi.getAlertTargetOptions(),
]);
if (rulesResult.status === "fulfilled") rules.value = rulesResult.value.data || [];
if (firingResult.status === "fulfilled") firing.value = firingResult.value.data || [];
Expand Down
98 changes: 98 additions & 0 deletions src/components/DeploymentHealthCheckModal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import DeploymentHealthCheckModal from "./DeploymentHealthCheckModal.vue";
import { deploymentsApi } from "@/services/api";

vi.mock("@/services/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/services/api")>();
return {
...actual,
deploymentsApi: { ...actual.deploymentsApi, updateMetadata: vi.fn().mockResolvedValue({ data: {} }) },
};
});

const metadata = {
name: "postgres",
type: "infrastructure",
primary_service: "postgres",
networking: { expose: false, domain: "", service: "postgres", container_port: 5432, protocol: "tcp" },
ssl: { enabled: false, auto_cert: false },
healthcheck: { path: "", interval: "30s" },
};

const mountModal = async (theme: "light" | "dark" = "light") => {
document.documentElement.dataset.theme = theme;
const wrapper = mount(DeploymentHealthCheckModal, {
props: { visible: false, deploymentName: "postgres", services: ["postgres"], metadata },
global: {
stubs: {
BaseModal: {
props: ["visible"],
template: "<div v-if='visible'><slot /><slot name='footer' /></div>",
},
},
},
});
await wrapper.setProps({ visible: true });
await flushPromises();
return wrapper;
};

describe("DeploymentHealthCheckModal", () => {
it.each(["light", "dark"] as const)("offers protocol-aware checks in %s mode", async (theme) => {
const wrapper = await mountModal(theme);
const typeSelect = wrapper.findAll("select")[0];

expect(typeSelect.findAll("option").map((option) => option.text())).toEqual([
"HTTP request",
"TCP connection",
"Container command",
]);
});

it("saves a TCP check without HTTP fields or routing changes", async () => {
const wrapper = await mountModal();
await wrapper.findAll("select")[0].setValue("tcp");
await wrapper
.findAll("button")
.find((button) => button.text().includes("Save and check"))!
.trigger("click");
await flushPromises();

expect(deploymentsApi.updateMetadata).toHaveBeenCalledWith("postgres", {
healthcheck: {
type: "tcp",
service: "postgres",
port: 5432,
path: "",
interval: "30s",
success_statuses: [],
response_contains: "",
command: "",
},
});
});

it("requires an exec command and saves it without a port", async () => {
const wrapper = await mountModal();
await wrapper.findAll("select")[0].setValue("exec");
await wrapper.find("textarea").setValue("pg_isready -U postgres");
await wrapper
.findAll("button")
.find((button) => button.text().includes("Save and check"))!
.trigger("click");
await flushPromises();

expect(deploymentsApi.updateMetadata).toHaveBeenLastCalledWith(
"postgres",
expect.objectContaining({
healthcheck: expect.objectContaining({
type: "exec",
service: "postgres",
port: 0,
command: "pg_isready -U postgres",
}),
}),
);
});
});
Loading
Loading