Skip to content
Open
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 .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jobs:
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_bots: '*'
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
Expand Down
62 changes: 60 additions & 2 deletions assets/components/ContainerTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
<tr
v-for="container in paginated"
:key="container.id"
v-memo="[container.id, statMode]"
v-memo="[container.id, statMode, container.state, container.logStatsVersion, container.anomalyScore]"
class="hover:bg-base-100/80!"
>
<td v-if="isVisible('name')" class="max-w-80 truncate">
Expand All @@ -96,7 +96,20 @@
</router-link>
</td>
<td v-if="isVisible('host')">{{ container.hostLabel }}</td>
<td v-if="isVisible('state')">{{ container.state }}</td>
<td v-if="isVisible('state')">
<span>{{ container.state }}</span>
<span
v-if="container.statusBadge"
class="badge ml-1 text-xs"
:class="{
'badge-error': container.statusBadge.type === 'error',
'badge-warning': container.statusBadge.type === 'warning',
'badge-info': container.statusBadge.type === 'info',
}"
>
{{ container.statusBadge.text }}
</span>
</td>
<td v-if="isVisible('created')">
<RelativeTime :date="container.created" />
</td>
Expand All @@ -106,6 +119,25 @@
<td v-if="isVisible('mem')">
<ContainerStatCell :container="container" type="mem" :host="hosts[container.host]" :mode="statMode" />
</td>
<td v-if="isVisible('logs')">
<div class="flex flex-row items-center gap-2">
<LogFrequencyChart class="h-4 flex-1" :chart-data="container.logStatsHistory" />
<span class="min-w-10 text-right text-sm tabular-nums">{{ totalLogRate(container) }}</span>
</div>
</td>
<td v-if="isVisible('hot')">
<span
v-if="container.anomalyScore > 0"
class="badge badge-sm tabular-nums"
:class="{
'badge-error': container.anomalyScore >= 50,
'badge-warning': container.anomalyScore >= 10 && container.anomalyScore < 50,
'badge-info': container.anomalyScore > 0 && container.anomalyScore < 10,
}"
>
{{ container.anomalyScore }}
</span>
</td>
</tr>
</tbody>
</table>
Expand Down Expand Up @@ -183,6 +215,24 @@ const fields: Record<
mobileVisible: false,
customClass: "min-w-48",
},
logs: {
label: "label.log-freq",
sortFunc: (a: Container, b: Container) => {
const aLast = a.logStatsHistory.at(-1);
const bLast = b.logStatsHistory.at(-1);
const aTotal = aLast ? aLast.info + aLast.warn + aLast.error + aLast.debug + aLast.fatal : 0;
const bTotal = bLast ? bLast.info + bLast.warn + bLast.error + bLast.debug + bLast.fatal : 0;
return (aTotal - bTotal) * direction.value;
},
mobileVisible: false,
customClass: "min-w-40",
},
hot: {
label: "label.hot",
sortFunc: (a: Container, b: Container) => (a.anomalyScore - b.anomalyScore) * direction.value,
mobileVisible: false,
customClass: "w-1",
},
};

const { containers } = defineProps<{
Expand Down Expand Up @@ -227,6 +277,14 @@ function sort(field: keys) {
direction.value = 1;
}
}
function totalLogRate(container: Container): string {
const last = container.logStatsHistory.at(-1);
if (!last) return "0";
const total = last.info + last.warn + last.error + last.debug + last.fatal;
if (total >= 1000) return `${(total / 1000).toFixed(1)}k`;
return `${total}`;
}

function isVisible(field: keys) {
return fields[field].mobileVisible || !isMobile.value;
}
Expand Down
104 changes: 104 additions & 0 deletions assets/components/LogFrequencyChart.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<template>
<div ref="chartContainer" class="flex items-end gap-[2px]">
<div
v-for="(bar, i) in downsampledBars"
:key="i"
class="flex min-h-px flex-1 flex-col justify-end rounded-t-sm"
:style="{ height: `${maxValue > 0 ? (bar.total / maxValue) * 100 : 0}%` }"
>
<div
v-if="bar.error > 0"
class="bg-error w-full rounded-t-sm"
:style="{ height: `${bar.total > 0 ? (bar.error / bar.total) * 100 : 0}%` }"
></div>
<div
v-if="bar.fatal > 0"
class="bg-error w-full"
:style="{ height: `${bar.total > 0 ? (bar.fatal / bar.total) * 100 : 0}%` }"
></div>
<div
v-if="bar.warn > 0"
class="bg-warning w-full"
:style="{ height: `${bar.total > 0 ? (bar.warn / bar.total) * 100 : 0}%` }"
></div>
<div
v-if="bar.info > 0"
class="bg-success w-full"
:style="{ height: `${bar.total > 0 ? (bar.info / bar.total) * 100 : 0}%` }"
></div>
<div
v-if="bar.debug > 0"
class="bg-info w-full"
:style="{ height: `${bar.total > 0 ? (bar.debug / bar.total) * 100 : 0}%` }"
></div>
</div>
</div>
</template>

<script setup lang="ts">
import type { LogFreq } from "@/models/Container";

const { chartData } = defineProps<{
chartData: LogFreq[];
}>();

interface AggregatedBar {
info: number;
warn: number;
error: number;
debug: number;
fatal: number;
total: number;
}

const chartContainer = ref<HTMLElement | null>(null);
const { width } = useElementSize(chartContainer);

const BAR_WIDTH = 3;
const GAP = 2;

const availableBars = computed(() => Math.max(1, Math.floor(width.value / (BAR_WIDTH + GAP))));

const downsampledBars = computed(() => {
const numBars = availableBars.value;
if (chartData.length === 0) return [];

if (chartData.length <= numBars) {
return chartData.map((d) => ({
info: d.info,
warn: d.warn,
error: d.error,
debug: d.debug,
fatal: d.fatal,
total: d.info + d.warn + d.error + d.debug + d.fatal,
}));
}

const bucketSize = Math.ceil(chartData.length / numBars);
const result: AggregatedBar[] = [];

for (let i = 0; i < numBars; i++) {
const start = i * bucketSize;
const end = Math.min(start + bucketSize, chartData.length);
const bucket = chartData.slice(start, end);

const agg: AggregatedBar = { info: 0, warn: 0, error: 0, debug: 0, fatal: 0, total: 0 };
for (const d of bucket) {
agg.info += d.info;
agg.warn += d.warn;
agg.error += d.error;
agg.debug += d.debug;
agg.fatal += d.fatal;
}
agg.total = agg.info + agg.warn + agg.error + agg.debug + agg.fatal;
result.push(agg);
}

return result.slice(-numBars);
});

const maxValue = computed(() => {
const dataMax = Math.max(0, ...downsampledBars.value.map((b) => b.total));
return Math.max(dataMax * 1.25, 1);
});
</script>
105 changes: 104 additions & 1 deletion assets/models/Container.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { ContainerHealth, ContainerJson, ContainerStat, ContainerState } from "@/types/Container";
import type { ContainerHealth, ContainerJson, ContainerStat, ContainerState, LogStat } from "@/types/Container";
import { Ref } from "vue";

export type Stat = Omit<ContainerStat, "id">;
export type LogFreq = Omit<LogStat, "id">;

const hosts = computed(() =>
config.hosts.reduce(
Expand All @@ -27,11 +28,23 @@ export class HistoricalContainer {
) {}
}

const defaultLogFreq: LogFreq = { info: 0, warn: 0, error: 0, debug: 0, fatal: 0 };
const LOG_STATS_HISTORY_SIZE = 60;
const RESTART_WINDOW_MS = 10 * 60 * 1000; // 10 minutes

export class Container {
private _stat: Ref<Stat>;
private _name: string;
private readonly _statsHistory: Ref<Stat[]>;
private readonly movingAverageStat: Ref<Stat>;
private readonly _logStatsHistory: Ref<LogFreq[]>;
private readonly _logStatsVersion: Ref<number>;

// Crash-loop / exit tracking
public lastExitCode: string | null = null;
private _restartTimestamps: number[] = [];
private _cachedRestartCount: number = 0;
private _restartCacheTime: number = 0;

constructor(
public readonly id: string,
Expand All @@ -57,10 +70,21 @@ export class Container {
const padding = Array(300 - recentStats.length).fill(defaultStat);
this._statsHistory = ref([...padding, ...recentStats]);
this.movingAverageStat = ref(stats.at(-1) || defaultStat);
const logPadding = Array(LOG_STATS_HISTORY_SIZE).fill(defaultLogFreq);
this._logStatsHistory = ref(logPadding);
this._logStatsVersion = ref(0);

this._name = name;
}

get logStatsHistory() {
return unref(this._logStatsHistory);
}

get logStatsVersion(): number {
return isRef(this._logStatsVersion) ? this._logStatsVersion.value : (this._logStatsVersion as unknown as number);
}

get statsHistory() {
return unref(this._statsHistory);
}
Expand Down Expand Up @@ -148,6 +172,85 @@ export class Container {
}
}

public updateLogStat(logStat: LogFreq) {
const history = isRef(this._logStatsHistory)
? this._logStatsHistory.value
: (this._logStatsHistory as unknown as LogFreq[]);
history.push(logStat);
if (history.length > LOG_STATS_HISTORY_SIZE) {
history.shift();
}
if (isRef(this._logStatsVersion)) {
this._logStatsVersion.value++;
} else {
// When unwrapped by reactive(), assign via property to trigger reactivity
(this as any)._logStatsVersion = (this._logStatsVersion as unknown as number) + 1;
}
}

public recordDie(exitCode?: string) {
this.lastExitCode = exitCode ?? null;
}

public recordRestart() {
const now = Date.now();
this._restartTimestamps.push(now);
// Keep only timestamps within the window
const cutoff = now - RESTART_WINDOW_MS;
this._restartTimestamps = this._restartTimestamps.filter((t) => t > cutoff);
}

get restartCount(): number {
const now = Date.now();
// Re-compute at most once per second to avoid repeated Date.now()+filter calls
if (now - this._restartCacheTime < 1000) {
return this._cachedRestartCount;
}
const cutoff = now - RESTART_WINDOW_MS;
// Prune stale timestamps while counting
this._restartTimestamps = this._restartTimestamps.filter((t) => t > cutoff);
this._cachedRestartCount = this._restartTimestamps.length;
this._restartCacheTime = now;
return this._cachedRestartCount;
}

get isCrashLooping(): boolean {
return this.restartCount >= 3;
}

get statusBadge(): { text: string; type: "error" | "warning" | "info" } | null {
const restarts = this.restartCount;
if (restarts >= 3) {
return { text: `${restarts} restarts`, type: "error" };
}
if (this.lastExitCode && this.lastExitCode !== "0" && this.state === "exited") {
const code = this.lastExitCode;
// 137=SIGKILL (may be OOM but not always), 143=SIGTERM
const label = code === "137" ? "Killed" : code === "143" ? "SIGTERM" : `Exit ${code}`;
return { text: label, type: "warning" };
}
if (this.health === "unhealthy") {
return { text: "unhealthy", type: "warning" };
}
return null;
}

/** Anomaly score for "hot" sorting — higher means more attention needed */
get anomalyScore(): number {
let score = 0;
const restarts = this.restartCount;
score += restarts * 20;
if (this.health === "unhealthy") score += 30;
if (this.lastExitCode && this.lastExitCode !== "0" && this.state === "exited") score += 15;
const recent = this.logStatsHistory.slice(-3);
for (const r of recent) {
score += r.error * 3 + r.fatal * 10 + r.warn;
}
if (this.movingAverage.cpu > 80) score += 10;
if (this.movingAverage.memory > 85) score += 10;
return score;
}

static fromJSON(c: ContainerJson): Container {
return new Container(
c.id,
Expand Down
24 changes: 22 additions & 2 deletions assets/stores/container.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { acceptHMRUpdate, defineStore } from "pinia";
import { Ref, UnwrapNestedRefs } from "vue";
import type { ContainerHealth, ContainerJson, ContainerStat } from "@/types/Container";
import type { ContainerHealth, ContainerJson, ContainerStat, LogStat } from "@/types/Container";
import { Container } from "@/models/Container";
import i18n from "@/modules/i18n";
import { Host } from "./hosts";
Expand Down Expand Up @@ -60,14 +60,34 @@ export const useContainerStore = defineStore("container", () => {
container.updateStat(rest);
}
});
es.addEventListener("container-log-stat", (e) => {
const logStat = JSON.parse((e as MessageEvent).data) as LogStat;
const container = allContainersById.value[logStat.id] as unknown as UnwrapNestedRefs<Container>;
if (container) {
const { id, ...rest } = logStat;
container.updateLogStat(rest);
}
});
es.addEventListener("container-event", (e) => {
const event = JSON.parse((e as MessageEvent).data) as { actorId: string; name: string; time: string };
const event = JSON.parse((e as MessageEvent).data) as {
actorId: string;
name: string;
time: string;
actorAttributes?: Record<string, string>;
};
const container = allContainersById.value[event.actorId];
if (container) {
switch (event.name) {
case "die":
container.state = "exited";
container.finishedAt = new Date(event.time);
container.recordDie(event.actorAttributes?.exitCode);
break;
case "start":
if (container.state === "exited") {
container.recordRestart();
}
container.state = "running";
break;
case "destroy":
container.state = "deleted";
Expand Down
Loading
Loading