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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ integration_test/ Integration tests against Loghub-2.0 datasets
`add-log` is a pure copy into `logs/` and never triggers discovery. `workspace import gcp` pulls a snapshot from GCP Cloud Logging through ADC credentials, lands it as enveloped NDJSON in `logs/`, and records provenance under `import-runs/<run-id>/record.json`; it never triggers discovery either. Each `workspace discover` starts a DiscoveryRun: reads ALL files in `logs/`, runs fresh Drain + semantic labeling, and writes run-scoped `patterns/` and `notes/`.
When `lapp web` starts, it marks any previous `QUEUED` or `RUNNING` DiscoveryRuns and ImportRuns as failed because those local workers no longer exist.
DiscoveryRun records persist structured `progress` and `error` fields; frontend code renders those facts into user-facing text.
The web Imports tab starts GCP imports through standard Operations, shows ImportRun history and final facts, and fills project and filter fields from recent queries across all workspaces.

```
workspace.Discover(ctx, cfg)
Expand Down
7 changes: 3 additions & 4 deletions cmd/lapp/workspace_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,19 +139,18 @@ func runWorkspaceImportGCP(cmd *cobra.Command, _ []string) error {
From: from,
To: to,
Limit: importGCPLimit,
Fetcher: func(fetchCtx context.Context, req workspace.ImportRequest) (workspace.ImportFetchResult, error) {
fetched, err := gcplog.FetchLines(fetchCtx, gcplog.FetchRequest{
Fetcher: func(fetchCtx context.Context, req workspace.ImportRequest, writeLine workspace.ImportLineWriter) (workspace.ImportFetchResult, error) {
fetched, err := gcplog.Fetch(fetchCtx, gcplog.FetchRequest{
Project: req.Project,
Filter: req.Filter,
From: req.From,
To: req.To,
Limit: req.Limit,
})
}, writeLine)
if err != nil {
return workspace.ImportFetchResult{}, err
}
return workspace.ImportFetchResult{
Lines: fetched.Lines,
Truncated: fetched.Truncated,
}, nil
},
Expand Down
4 changes: 4 additions & 0 deletions docs/web-app-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ LAPP Web is a local web app for working with local LAPP workspaces. It starts fr
- Show latest discovery status and progress.
- Disable log upload, log deletion, and starting another discovery while a discovery run is running.

### GCP Imports

The Imports tab starts GCP imports with project, filter, time range, and limit fields. Quick time ranges cover the common one hour, six hour, and twenty four hour cases. The frontend polls the standard Operation while work is active. ImportRun records remain the source for workspace history, provenance, final counts, truncation, and structured errors. Recent project and filter pairs come from ImportRun records across every workspace. Choosing one fills both fields without creating another stored resource.

### Patterns

- List patterns from the selected or latest successful discovery run.
Expand Down
326 changes: 326 additions & 0 deletions frontend/src/import-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { AlertTriangle, CloudDownload, History } from "lucide-react";
import { useState } from "react";
import { ImportRun, ImportRunState } from "./gen/lapp/web/v1/web_pb";
import { useAppStore } from "./store";

export function ImportPanel({ hasActiveRun }: { hasActiveRun: boolean }) {
const importRuns = useAppStore((state) => state.importRuns);
const recentImportQueries = useAppStore((state) => state.recentImportQueries);
const busy = useAppStore((state) => state.busy);
const runAction = useAppStore((state) => state.runAction);
const startImport = useAppStore((state) => state.startImport);
const [project, setProject] = useState("");
const [filter, setFilter] = useState("");
const [range, setRange] = useState(() => rangeForHours(1));
const [selectedHours, setSelectedHours] = useState<number | null>(1);
const [limit, setLimit] = useState("100000");
const latestRun = importRuns[0];
const disabled = busy || hasActiveRun;

const useQuickRange = (hours: number) => {
setSelectedHours(hours);
setRange(rangeForHours(hours));
};

const submit = async () => {
const from = new Date(range.from);
const to = new Date(range.to);
const parsedLimit = Number(limit);
if (!project.trim()) {
throw new Error("Project is required.");
}
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
throw new Error("From and to are required.");
}
if (from > to) {
throw new Error("From must not be after to.");
}
if (!Number.isInteger(parsedLimit) || parsedLimit < 1) {
throw new Error("Limit must be a positive whole number.");
}

await startImport({
project: project.trim(),
filter,
from,
to,
limit: parsedLimit
});
};

return (
<section className="import-view">
<div className="import-top">
<form
className="panel import-form"
onSubmit={(event) => {
event.preventDefault();
void runAction(submit);
}}
>
<header className="panel-heading">
<div>
<h2>Import GCP logs</h2>
<p>The server uses local Application Default Credentials.</p>
</div>
<CloudDownload size={22} />
</header>

<label>
<span>Recent query</span>
<select
value=""
disabled={disabled || recentImportQueries.length === 0}
onChange={(event) => {
const query = recentImportQueries[Number(event.target.value)];
if (!query) return;
setProject(query.project);
setFilter(query.filter);
}}
>
<option value="">Choose a past query</option>
{recentImportQueries.map((query, index) => (
<option key={`${query.project}\u0000${query.filter}`} value={index}>{recentQueryLabel(query.project, query.filter)}</option>
))}
</select>
</label>

<div className="form-grid">
<label>
<span>Project</span>
<input
value={project}
required
disabled={disabled}
placeholder="my-gcp-project"
onChange={(event) => setProject(event.target.value)}
/>
</label>
<label>
<span>Limit</span>
<input
type="number"
min="1"
step="1"
value={limit}
required
disabled={disabled}
onChange={(event) => setLimit(event.target.value)}
/>
</label>
</div>

<label>
<span>Filter</span>
<input
value={filter}
disabled={disabled}
placeholder='severity >= ERROR'
onChange={(event) => setFilter(event.target.value)}
/>
</label>

<fieldset disabled={disabled}>
<legend>Time range</legend>
<div className="quick-ranges">
{[1, 6, 24].map((hours) => (
<button
key={hours}
type="button"
className={selectedHours === hours ? "selected" : ""}
onClick={() => useQuickRange(hours)}
>
Last {hours}h
</button>
))}
</div>
<div className="form-grid">
<label>
<span>From</span>
<input
type="datetime-local"
value={range.from}
required
onChange={(event) => {
setSelectedHours(null);
setRange((current) => ({ ...current, from: event.target.value }));
}}
/>
</label>
<label>
<span>To</span>
<input
type="datetime-local"
value={range.to}
required
onChange={(event) => {
setSelectedHours(null);
setRange((current) => ({ ...current, to: event.target.value }));
}}
/>
</label>
</div>
</fieldset>

<div className="form-actions">
<button type="submit" disabled={disabled || !project.trim()}>
<CloudDownload size={17} />
Start Import
</button>
</div>
</form>

<section className={latestRun?.state === ImportRunState.FAILED ? "panel import-latest failed" : "panel import-latest"}>
<header className="panel-heading">
<div>
<h2>Latest import</h2>
<p>Current state and final result.</p>
</div>
<History size={22} />
</header>
{latestRun ? (
<>
<div className="import-state-line">
<StateBadge state={latestRun.state} />
<span>{formatTimestamp(latestRun.startedAt)}</span>
</div>
<p className="import-message">{importRunMessage(latestRun)}</p>
<dl>
<dt>Project</dt>
<dd>{latestRun.project}</dd>
<dt>Range</dt>
<dd>{formatRange(latestRun)}</dd>
<dt>Limit</dt>
<dd>{latestRun.limit.toLocaleString()}</dd>
</dl>
{latestRun.truncated && (
<div className="truncation-warning">
<AlertTriangle size={17} />
Results reached the limit and were truncated.
</div>
)}
</>
) : (
<p className="empty-copy">No imports for this workspace.</p>
)}
</section>
</div>

<section className="panel import-history">
<header className="panel-heading">
<div>
<h2>Import history</h2>
<p>Newest runs appear first.</p>
</div>
</header>
{importRuns.length > 0 ? (
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Started</th>
<th>Project and filter</th>
<th>Range</th>
<th>State</th>
<th>Entries</th>
<th>File</th>
</tr>
</thead>
<tbody>
{importRuns.map((run) => (
<tr key={run.name}>
<td>{formatTimestamp(run.startedAt)}</td>
<td>
<strong>{run.project}</strong>
<code className="filter-value">{run.filter || "All logs"}</code>
</td>
<td>{formatRange(run)}</td>
<td>
<StateBadge state={run.state} />
{run.error && (
<span className="run-error">
{run.error.code}: {run.error.message}
</span>
)}
</td>
<td>
{run.state === ImportRunState.SUCCEEDED ? run.entryCount.toLocaleString() : ""}
{run.truncated && <span className="small-warning">Truncated</span>}
</td>
<td>{run.logFileName}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="empty-copy">No import history.</p>
)}
</section>
</section>
);
}

function StateBadge({ state }: { state: ImportRunState }) {
return <span className={`state-badge ${importStateClass(state)}`}>{importStateLabel(state)}</span>;
}

function importRunMessage(run: ImportRun) {
switch (run.state) {
case ImportRunState.QUEUED:
return "Waiting to start.";
case ImportRunState.RUNNING:
return "Reading matching log entries from GCP.";
case ImportRunState.SUCCEEDED:
return `${run.entryCount.toLocaleString()} entries written to ${run.logFileName}. Start discovery when ready.`;
case ImportRunState.FAILED:
return run.error ? `${run.error.code}: ${run.error.message}` : "Import failed without an error message.";
default:
return "Waiting for state.";
}
}

function importStateLabel(state: ImportRunState) {
return ImportRunState[state]?.toLowerCase() ?? "unknown";
}

function importStateClass(state: ImportRunState) {
switch (state) {
case ImportRunState.SUCCEEDED:
return "succeeded";
case ImportRunState.FAILED:
return "failed";
case ImportRunState.QUEUED:
case ImportRunState.RUNNING:
return "running";
default:
return "";
}
}

function recentQueryLabel(project: string, filter: string) {
return filter ? `${project} · ${filter}` : `${project} · All logs`;
}

function formatRange(run: ImportRun) {
return `${formatTimestamp(run.from)} to ${formatTimestamp(run.to)}`;
}

function formatTimestamp(value: ImportRun["from"]) {
return value ? timestampDate(value).toLocaleString() : "";
}

function rangeForHours(hours: number) {
const to = new Date();
const from = new Date(to.getTime() - hours * 60 * 60 * 1000);
return {
from: datetimeLocalValue(from),
to: datetimeLocalValue(to)
};
}

function datetimeLocalValue(value: Date) {
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60 * 1000);
return local.toISOString().slice(0, 16);
}
Loading
Loading