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
46 changes: 46 additions & 0 deletions cockpit/ui/src-tauri/src/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
//! - HALYARD_CONFIG_DIR (default ".") — CWD for the spawn; Halyard resolves config
//! relative to CWD.
//! - AUDIENCE_API_URL (default "http://localhost:8080").
//! - TELLTALE_BASE_URL — the Worker origin, e.g.
//! "https://telltale.openbarclay.workers.dev". No default: unset means the
//! Intake lane is simply not configured, which is different from unreachable.
//! - TELLTALE_TOKEN — the operator read token (`OPERATOR_READ_TOKEN` on the
//! Worker). The desktop host holds it so it never reaches the webview.

use serde_json::Value;
use std::process::Command;
Expand Down Expand Up @@ -99,6 +104,47 @@ pub async fn audience_posts() -> Result<Value, String> {
Ok(unwrap_posts(body))
}

fn telltale_base() -> Option<String> {
// No default. An unset base URL means "Intake is not configured on this
// machine", which the adapter must be able to tell apart from "configured but
// unreachable" — the second greys a lane, the first should not invent one.
std::env::var("TELLTALE_BASE_URL").ok().filter(|s| !s.trim().is_empty())
}

/// `GET {TELLTALE_BASE_URL}/v1/issues` → `{ issues, errors }` (spec §6.3), passed
/// through to the feedback adapter unchanged.
///
/// The shape is pinned on both sides by the wire contract in
/// `adapters/contracts/telltale-issues.contract.json`; this command deliberately
/// does no reshaping, so there is exactly one place the payload is understood.
///
/// §6.3's per-repo `errors` array is why this returns the whole envelope rather
/// than just the issues: one bad repo must not blank the entire feedback lane.
#[tauri::command]
pub async fn feedback_issues() -> Result<Value, String> {
let base = telltale_base().ok_or_else(|| "TELLTALE_BASE_URL is not set".to_string())?;
let token = std::env::var("TELLTALE_TOKEN")
.map_err(|_| "TELLTALE_TOKEN is not set".to_string())?;

let url = format!("{}/v1/issues", base.trim_end_matches('/'));
let resp = reqwest::Client::new()
.get(&url)
.bearer_auth(token)
.send()
.await
.map_err(|e| format!("telltale /v1/issues request failed: {e}"))?;

if !resp.status().is_success() {
// Deliberately does not echo the body: a 401 from this endpoint is an
// operator-token problem, and the body is not worth risking in a log.
return Err(format!("telltale /v1/issues returned {}", resp.status()));
}

resp.json::<Value>()
.await
.map_err(|e| format!("telltale /v1/issues body was not JSON: {e}"))
}

/// Normalize the `/posts` body to a JSON array — tolerate a bare array or a common
/// `{ posts | data | items: [...] }` envelope so the adapter always sees a list.
fn unwrap_posts(body: Value) -> Value {
Expand Down
1 change: 1 addition & 0 deletions cockpit/ui/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ pub fn run() {
dashboard::halyard_queue,
dashboard::audience_health,
dashboard::audience_posts,
dashboard::feedback_issues,
local_projects::scan_local_projects,
])
.setup(|app| {
Expand Down
3 changes: 2 additions & 1 deletion cockpit/ui/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import Switcher, { type ViewEntry } from './lib/Switcher.svelte';
import Dashboard from './views/Dashboard.svelte';
import ApprovalOverlay, { type ApprovalRequest } from './lib/ApprovalOverlay.svelte';
import { tauriHalyardReader, tauriAudienceReader } from './lib/dashboard/api';
import { tauriHalyardReader, tauriAudienceReader, tauriFeedbackReader } from './lib/dashboard/api';
import type { LocalReader, LocalProjectDoc } from './lib/dashboard/adapters/local';
// PLUGIN RUNTIME (Lane S): the sandboxed view-plugin bridge (Lane V) + app-plugin
// discovery (Lane A). The one topbar switcher unifies host views + both plugin kinds.
Expand Down Expand Up @@ -379,6 +379,7 @@
<Dashboard
halyardReader={tauriHalyardReader}
audienceReader={tauriAudienceReader}
feedbackReader={tauriFeedbackReader}
{localReader}
fleetSnapshots={fleet.snapshots()}
onFleetPhase={(cb) => fleet.onPhase(cb)}
Expand Down
8 changes: 8 additions & 0 deletions cockpit/ui/src/lib/dashboard/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { invoke } from '@tauri-apps/api/core';
import type { HalyardReader, HalyardReleaseStatus, HalyardProposal } from './adapters/halyard';
import type { AudienceReader, AudiencePost } from './adapters/audience';
import type { FeedbackReader, FeedbackIssuesResponse } from './adapters/feedback';

/** Reads Halyard by spawning the `halyard` CLI (Rust side parses stdout JSON). */
export const tauriHalyardReader: HalyardReader = {
Expand All @@ -21,3 +22,10 @@ export const tauriAudienceReader: AudienceReader = {
health: () => invoke<boolean>('audience_health'),
posts: () => invoke<AudiencePost[]>('audience_posts'),
};

/** §6.3 — the whole `{ issues, errors }` envelope, not just the array. A repo that
* failed to answer must reach the adapter; dropping `errors` would let one bad
* repo silently blank the lane instead of flagging it. */
export const tauriFeedbackReader: FeedbackReader = {
issues: () => invoke<FeedbackIssuesResponse>('feedback_issues'),
};
60 changes: 60 additions & 0 deletions cockpit/ui/src/lib/dashboard/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
newBoard,
pollHalyard,
pollAudience,
pollFeedback,
pollLocal,
seedFleet,
applyFleetPhase,
Expand All @@ -15,6 +16,7 @@ import {
} from './store';
import type { HalyardReader } from './adapters/halyard';
import type { AudienceReader } from './adapters/audience';
import type { FeedbackReader, TelltaleIssue } from './adapters/feedback';
import type { LocalReader } from './adapters/local';
import type { Snapshot } from '../types';

Expand All @@ -29,6 +31,18 @@ const audienceReader = (alive: boolean, posts: any[] = []): AudienceReader => ({
health: async () => alive,
posts: async () => posts,
});
const feedbackIssue = (over: Partial<TelltaleIssue> = {}): TelltaleIssue => ({
repo: 'adbarc92/hexy', number: 1, title: 'crash on launch', body: '',
kind: 'crash', project: 'hexy', isOpen: true, hasAssignee: false,
createdIso: '2026-06-08T12:00:00Z', updatedIso: '2026-06-08T12:00:00Z',
labels: ['telltale', 'telltale:crash'],
url: 'https://github.com/adbarc92/hexy/issues/1',
...over,
});
const feedbackReader = (
issues: TelltaleIssue[] = [],
errors: Array<{ project: string; message: string }> = [],
): FeedbackReader => ({ issues: async () => ({ issues, errors }) });

describe('board composition', () => {
it('composes cards from multiple sources, keyed by projectId', async () => {
Expand Down Expand Up @@ -178,3 +192,49 @@ function fleetCardStub(id: string, stage: any) {
updatedIso: '2026-06-09T12:00:00Z', staleAfterSec: 120, health: 'ok' as const,
};
}

// ── Intake lane (§6) ────────────────────────────────────────────────────────
// The transport for this source landed after the adapter did, so these pin the
// store seam specifically: that `pollFeedback` writes under the 'feedback'
// source and that one bad repo cannot take the lane down with it.
describe('feedback poll', () => {
it('writes cards under the feedback source', async () => {
let board = newBoard();
board = await pollFeedback(board, feedbackReader([feedbackIssue()]), {}, NOW);

const cards = cardList(board).filter((c) => c.source === 'feedback');
expect(cards.length).toBe(1);
expect(cards[0].projectId).toBe('feedback:hexy');
});

it('replaces only the feedback lane, leaving other sources standing', async () => {
let board = newBoard();
board = await pollHalyard(
board,
halyardReader([{ release_id: 'r1', app: 'aurora', surface: 'web', version: '4.2', state: 'live', flag: null, waiting_on: '' }]),
{},
NOW,
);
board = await pollFeedback(board, feedbackReader([feedbackIssue()]), {}, NOW);
// A second poll returning nothing must clear feedback and touch nothing else.
board = await pollFeedback(board, feedbackReader([]), {}, NOW);

expect(cardList(board).some((c) => c.source === 'halyard')).toBe(true);
expect(cardList(board).filter((c) => c.source === 'feedback').length).toBe(0);
});

it('§6.3 — a project that failed to answer does not blank the lane', async () => {
let board = newBoard();
board = await pollFeedback(
board,
feedbackReader([feedbackIssue({ project: 'hexy' })], [{ project: 'lineage', message: 'config_error: no token' }]),
{},
NOW,
);

// The healthy project still renders; the failure is surfaced, not swallowed.
const cards = cardList(board).filter((c) => c.source === 'feedback');
expect(cards.some((c) => c.projectId === 'feedback:hexy')).toBe(true);
expect(JSON.stringify(cards)).toContain('lineage');
});
});
11 changes: 11 additions & 0 deletions cockpit/ui/src/lib/dashboard/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { STAGE_ORDINAL, isPipelineStage } from './model';
import type { Phase, Snapshot } from '../types';
import { halyardCards, type HalyardReader } from './adapters/halyard';
import { audienceCards, type AudienceReader } from './adapters/audience';
import { feedbackCards, type FeedbackReader } from './adapters/feedback';
import { localCards, type LocalReader } from './adapters/local';
import { fleetCard, fleetCardsFromSnapshots, type FleetUnitState } from './adapters/fleet';
import { appPluginCard, type PluginUnit, type PluginState } from './adapters/appPlugin';
Expand Down Expand Up @@ -76,6 +77,16 @@ export async function pollAudience(
return replaceSource(state, 'audience', cards);
}

export async function pollFeedback(
state: BoardState,
reader: FeedbackReader,
overrides: Record<string, StageOverride> = {},
now: () => Date = () => new Date(),
): Promise<BoardState> {
const cards = await feedbackCards(reader, { overrides, now });
return replaceSource(state, 'feedback', cards);
}

export async function pollLocal(
state: BoardState,
reader: LocalReader,
Expand Down
6 changes: 6 additions & 0 deletions cockpit/ui/src/views/Dashboard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
newBoard,
pollHalyard,
pollAudience,
pollFeedback,
pollLocal,
seedFleet,
applyFleetPhase,
Expand All @@ -21,6 +22,7 @@
import type { ProjectCard, Stage } from '../lib/dashboard/model';
import type { HalyardReader } from '../lib/dashboard/adapters/halyard';
import type { AudienceReader } from '../lib/dashboard/adapters/audience';
import type { FeedbackReader } from '../lib/dashboard/adapters/feedback';
import type { LocalReader } from '../lib/dashboard/adapters/local';
import type { Snapshot, Phase } from '../lib/types';
import type { PluginUnit } from '../lib/dashboard/adapters/appPlugin';
Expand All @@ -30,6 +32,7 @@
interface Props {
halyardReader?: HalyardReader;
audienceReader?: AudienceReader;
feedbackReader?: FeedbackReader;
localReader?: LocalReader;
fleetSnapshots?: Snapshot[];
/** Subscribe to fleet `phase_changed`; returns an unsubscribe. */
Expand All @@ -44,6 +47,7 @@
let {
halyardReader,
audienceReader,
feedbackReader,
localReader,
fleetSnapshots = [],
onFleetPhase,
Expand Down Expand Up @@ -72,6 +76,7 @@
try {
if (halyardReader) board = await pollHalyard(board, halyardReader, {}, () => new Date());
if (audienceReader) board = await pollAudience(board, audienceReader, {}, () => new Date());
if (feedbackReader) board = await pollFeedback(board, feedbackReader, {}, () => new Date());
if (localReader) board = await pollLocal(board, localReader);
} finally {
pulling = false;
Expand Down Expand Up @@ -129,6 +134,7 @@
'app-plugin': 'APP',
manual: 'MANUAL',
local: 'LOCAL',
feedback: 'FEEDBACK',
};

// §8 #2: actions deep-link OUT. Tauri's opener handles custom + http schemes; in a
Expand Down
Loading